写 Laravel 测试代码 (一)

本文主要探讨写数据库测试。

写laravel程序时,除了写生产代码,还需要写测试代码。其中,写数据库测试比较麻烦,因为需要针对每一个test case需要建立好数据集,该次test case污染的数据表还需要恢复现场,避免影响下一个test case运行,同时还得保证性能问题,否则随着程序不断膨胀,测试数量也越多,那每一次测试运行需要花费大量时间。

有两个比较好的方法可以提高数据库测试性能:

  1. 对大量的tests按照功能分组。如有1000个tests,可以按照业务功能分组,如group1:1-200, group2:201-800, group3: 801-1000。这样可以并发运行每组测试包裹。
  2. 只恢复每个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可以在短时间内运行完毕。

最后,写测试代码是必须的,好处非常多,随着项目程序越来越大,就会深深感觉到写测试是必须的,一劳永逸,值得花时间投资。也是作为一名软件工程师的必备要求。

RightCapital招聘Laravel DevOps

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由 Summer 于 6年前 加精
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 18

一谈到专业 就没人敢回复 了 哈哈哈哈

6年前 评论
medz

我为了避免污染,专门增加了一个 env.test 来进行测试。

6年前 评论

这个强势

6年前 评论

fixtures写成像配置文件那样的数组格式可以吗

6年前 评论

@Dexter 写成数组格式,估计是hard code在代码里,最好不要这么做。。fixtures放在yaml文件岂不是更友好,利于阅读。。baseline 放在json文件里,同样道理。

6年前 评论

@lx1036 baseline是什么,看不懂。。。测试填充数据是人工设计的还是用factory那样的工具生成呢,如果是人工设计的我觉得写成数组也可以,不过我本来问这个是想知道会不会有性能上的优势。。。
还有两个地方不懂的:

  1. 为什么TestCase中会出现使用多个Seeder来填充同一组数据表的情况呢
  2. 测试中调用reseedInNextTest,然后在TestCase的setUp中reseed的话,意思是在这个测试开始时先定义好下个测试要填充的数据吗,这会不会有点不直观

多谢指教:pray:

6年前 评论

@Dexter 嗷嗷, baseline 还没单独写篇文章。。 测试数据是人工写的,放在yaml文件里。。

  1. 嗷嗷,明白你啥意思,是因为可能有多个yaml文件,设计上就是为了分拆的,如叫做 basic.yml 和 extension.yml,那就做成两个 DatabaseSeeder: BasicDatabaseSeeder 和 ExtensionDatabaseSeeder,负责分别填充对应测试数据么。。不建议使用 model factory 来创建fixture,人工写的yaml放入版本控制(任何一个数据修改会影响哪些test case一目了然, git diff就知道了),model factory就不好这样做了(如 model factory fake 的一个name,每次运行可能都不一样,这样不能精细控制),不利于实际操作(可行但不实用)。。

  2. 如果当前test case 污染了几张表, 那就在当前test case中定义 reseedInNextTest = [$table1, $table2] ,这样下一个test case运行前会只重刷下$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的设计有很多技巧,让测试代码写的更友好,这些天我再写一篇文章聊聊这个。。

6年前 评论

@lx1036 :flushed: 把tablesToReseed设置为空就是为了把所有数据都填进去。。 之前一直以为是要用到相关的表才初始化。这下明白多了:sunglasses:

6年前 评论

@Dexter 对,tablesToReseed 为空是重刷所有表,一般在所有测试运行前只执行一次,后面是哪些表被污染了,就重刷哪些表。。

6年前 评论

@lx1036 感谢大佬,已经按这种设计把测试跑起来了:+1:

6年前 评论

@Dexter 要是有什么问题可以留言交流。。

6年前 评论

@wakasann 没有去替换原有的 db:seed 命令,用的还是原有的 db:seed,只是给这个命令增加了一个 option,即 --tables
另外,这个有关测试的小系列文章,我还以为没什么人看呢,后来就不继续更了。。。

5年前 评论

@lx1036 抱歉,是我自己搞错了 make:seeddb:seed 的用处,我把自己评论删掉了 :cry: ,因为刚开始学习给自己的项目写单元测试,所以认真看了和实践。虽然不更,你写的测试的小系列文章我还是会看的 :smile: ,感觉有帮助

5年前 评论

@lx1036 明白了,db:seed--tables参数,是为了能向指定的表中填充数据

5年前 评论

@wakasann 这个小系列就是把我们这边的测试经验,给分享出来的,这套测试经验是经过4年多的大型项目实践过的。。
如果要是后续有大量人阅读和提问,我再想想是不是可以重新写一版详细的。。这个小系列也只是随意写的,比较粗糙。。

5年前 评论

@lx1036 感谢,第1章尝试成功, 通过 php artisan db:seed --class=SimpleYamlSeeder --tables=member,数据库中会员数据填充成功。database.path('seeds/simple.yml'),在 5.4,需要改为database_path('seeds/simple.yml') 来获取这个路径的

5年前 评论

@wakasann 对,是 database_path,是我手抖了。

5年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!