写 Laravel 测试代码 (一)
本文主要探讨写数据库测试。
写laravel程序时,除了写生产代码,还需要写测试代码。其中,写数据库测试比较麻烦,因为需要针对每一个test case
需要建立好数据集,该次test case
污染的数据表还需要恢复现场,避免影响下一个test case
运行,同时还得保证性能问题,否则随着程序不断膨胀,测试数量也越多,那每一次测试运行需要花费大量时间。
有两个比较好的方法可以提高数据库测试性能:
- 对大量的
tests
按照功能分组。如有1000个tests,可以按照业务功能分组,如group1:1-200, group2:201-800, group3: 801-1000
。这样可以并发运行
每组测试包裹。 - 只恢复每个
test case
污染的表,而不需要把所有的数据表重新恢复,否则表数量越多测试代码执行越慢。
这里聊下方法2的具体做法。
假设程序有50张表,每次运行测试时首先需要为每组构建好独立的对应数据库,然后创建数据表,最后就是填充测试数据(fixtures
)。fixtures
可用yml
格式定义,既直观也方便维护,如:
#simple.yml
accounts:
- id: 1
person_id: 2
type: investment
is_included: true
- id: 2
person_id: 2
type: investment
is_included: true
transactions:
- account_id: 1
posted_date: '2017-01-01'
amount: 10000
transaction_category_id: 1
- account_id: 2
posted_date: '2017-01-02'
amount: 10001
transaction_category_id: 2
然后需要写个yamlSeeder class
来把数据集填充到临时数据库里:
abstract class YamlSeeder extends \Illuminate\Database\Seeder
{
private $files;
public function __construct(array $files)
{
$this->files = $files
}
public function run(array $tables = []): void
{
// Close unique and foreign key constraint
$db = $this->container['db'];
$db->statement('SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0;');
$db->statement('SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0;');
foreach($this->files as $file) {
...
// Convert yaml data to array
$fixtures = \Symfony\Component\Yaml\Yaml::parse(file_get_contents($file));
...
foreach($fixtures as $table => $data) {
// Only seed specified tables, it is important!!!
if ($tables && !in_array($table, $tables, true)) {
continue;
}
$db->table($table)->truncate();
if (!$db->table($table)->insert($data)) {
throw new \RuntimeException('xxx');
}
}
...
}
// Open unique and foreign key constraint
$db->statement('SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS;');
$db->statement('SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS;');
}
}
class SimpleYamlSeeder extends YamlSeeder
{
public function __construct()
{
parent::__construct([database_path('seeds/simple.yml')]);
}
}
上面的代码有一个关键处是参数$tables
:如果参数是空数组,就把所有数据表数据插入随机数据库里;如果是指定的数据表,只重刷指定的数据表。这样会很大提高数据库测试的性能,因为可以在每一个test case里只需要指定本次测试所污染的数据表。在tests/TestCase.php
中可以在setUp()
设置数据库重装操作:
abstract class TestCase extends \Illuminate\Foundation\Testing\TestCase
{
protected static $tablesToReseed = [];
public function seed($class = 'DatabaseSeeder', array $tables = []): void
{
$this->artisan('db:seed', ['--class' => $class, '--tables' => implode(',', $tables)]);
}
protected function reseed(): void
{
// TEST_SEEDERS is defined in phpunit.xml, e.g. <env name="TEST_SEEDERS" value="\SimpleYamlSeeder"/>
$seeders = env('TEST_SEEDERS') ? explode(',', env('TEST_SEEDERS')) : [];
if ($seeders && is_array(static::$tablesToReseed)) {
foreach ($seeders as $seeder) {
$this->seed($seeder, static::$tablesToReseed);
}
}
\Cache::flush();
static::$tablesToReseed = false;
}
protected static function reseedInNextTest(array $tables = []): void
{
static::$tablesToReseed = $tables;
}
}
这样就可以在每一个test case
中定义本次污染的数据表,保证下一个test case
在运行前重刷下被污染的数据表,如:
final class AccountControllerTest extends TestCase
{
...
public function testUpdateAccount()
{
static::reseedInNextTest([Account::TABLE, Transaction::TABLE]);
...
}
}
这样会极大提高数据库测试效率,不推荐使用Laravel给出的\Illuminate\Foundation\Testing\DatabaseMigrations 和 \Illuminate\Foundation\Testing\DatabaseTransactions
,效率并不高。
laravel的db:seed
命令没有--tables
这个options
,所以需要扩展\Illuminate\Database\Console\Seeds\SeedCommand
:
class SeedCommand extends \Illuminate\Database\Console\Seeds\SeedCommand
{
public function fire()
{
if (!$this->confirmToProceed()) {
return;
}
$this->resolver->setDefaultConnection($this->getDatabase());
Model::unguarded(function () {
$this->getSeeder()->run($this->getTables());
});
}
protected function getTables()
{
$tables = $this->input->getOption('tables');
return $tables ? explode(',', $tables) : [];
}
protected function getOptions()
{
$options = parent::getOptions();
$options[] = ['tables', null, InputOption::VALUE_OPTIONAL, 'A comma-separated list of tables to seed, all if left empty'];
return $options;
}
}
当然还得写SeedServiceProvider()来覆盖原有的Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::registerSeedCommand()中注册的command.seed
,然后在config/app.php
中注册:
class SeedServiceProvider extends ServiceProvider
{
/**
* Indicates if loading of the provider is deferred.
*
* @var bool
*/
protected $defer = true;
/**
* @see \Illuminate\Database\SeedServiceProvider::registerSeedCommand()
*/
public function register()
{
$this->app->singleton('command.seed', function ($app) {
return new SeedCommand($app['db']);
});
$this->commands('command.seed');
}
public function provides()
{
return ['command.seed'];
}
}
OK,这样所有的工作都做完了。。以后写数据库测试性能会提高很多,大量的test case
可以在短时间内运行完毕。
最后,写测试代码
是必须的,好处非常多,随着项目程序越来越大,就会深深感觉到写测试是必须的,一劳永逸,值得花时间投资。也是作为一名软件工程师的必备要求。
本作品采用《CC 协议》,转载必须注明作者和本文链接
一谈到专业 就没人敢回复 了 哈哈哈哈
@echobool 什么?
我为了避免污染,专门增加了一个
env.test
来进行测试。这个强势
fixtures
写成像配置文件那样的数组格式可以吗@Dexter 写成数组格式,估计是hard code在代码里,最好不要这么做。。fixtures放在yaml文件岂不是更友好,利于阅读。。baseline 放在json文件里,同样道理。
@lx1036 baseline是什么,看不懂。。。测试填充数据是人工设计的还是用factory那样的工具生成呢,如果是人工设计的我觉得写成数组也可以,不过我本来问这个是想知道会不会有性能上的优势。。。
还有两个地方不懂的:
多谢指教:pray:
@Dexter 嗷嗷, baseline 还没单独写篇文章。。 测试数据是人工写的,放在yaml文件里。。
嗷嗷,明白你啥意思,是因为可能有多个yaml文件,设计上就是为了分拆的,如叫做 basic.yml 和 extension.yml,那就做成两个 DatabaseSeeder: BasicDatabaseSeeder 和 ExtensionDatabaseSeeder,负责分别填充对应测试数据么。。不建议使用 model factory 来创建fixture,人工写的yaml放入版本控制(任何一个数据修改会影响哪些test case一目了然, git diff就知道了),model factory就不好这样做了(如 model factory fake 的一个name,每次运行可能都不一样,这样不能
精细控制
),不利于实际操作(可行但不实用)。。只重刷
下$table1, $table2, 这样性能高;如果当前test case没有污染任何表,不用给reseedInNextTest值,所以叫做reseedInNextTest, 而不是reseedInCurrentTest之类的。。关于baseline,就是assert的时候,一般都是hard code那些预期数据(expected data), 如assertSame(['id' => 1036, 'type' => 'credit_card', 'balance' => 1000], $actual_data),这里就是hard code 预期数据,最好不要这么做,而应该是放入json 文件里,叫做baseline。。这样一个逻辑产生的 json response数据(也就是actual data),和这个从json文件里读取的expected data进行比较,任何一个数据不同都可以
精细
的知道(git diff知道)。。baseline的设计有很多技巧,让测试代码写的更友好,这些天我再写一篇文章聊聊这个。。@lx1036 :flushed: 把
tablesToReseed
设置为空就是为了把所有数据都填进去。。 之前一直以为是要用到相关的表才初始化。这下明白多了:sunglasses:@Dexter 对,tablesToReseed 为空是重刷所有表,一般在所有测试运行前
只执行一次
,后面是哪些表被污染了,就重刷哪些表。。@lx1036 感谢大佬,已经按这种设计把测试跑起来了:+1:
@Dexter 要是有什么问题可以留言交流。。
@wakasann 没有去替换原有的
db:seed
命令,用的还是原有的db:seed
,只是给这个命令增加了一个option
,即--tables
。另外,这个有关测试的小系列文章,我还以为没什么人看呢,后来就不继续更了。。。
@lx1036 抱歉,是我自己搞错了
make:seed
和db:seed
的用处,我把自己评论删掉了 :cry: ,因为刚开始学习给自己的项目写单元测试,所以认真看了和实践。虽然不更,你写的测试的小系列文章我还是会看的 :smile: ,感觉有帮助@lx1036 明白了,
db:seed
加--tables
参数,是为了能向指定的表中填充数据@wakasann 这个小系列就是把我们这边的测试经验,给分享出来的,这套测试经验是经过4年多的大型项目实践过的。。
如果要是后续有大量人阅读和提问,我再想想是不是可以重新写一版详细的。。这个小系列也只是随意写的,比较粗糙。。
@lx1036 感谢,第1章尝试成功, 通过
php artisan db:seed --class=SimpleYamlSeeder --tables=member
,数据库中会员数据填充成功。database.path('seeds/simple.yml')
,在 5.4,需要改为database_path('seeds/simple.yml')
来获取这个路径的@wakasann 对,是
database_path
,是我手抖了。