写 Laravel 测试代码 (二)

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

写 Laravel 测试代码 (一) 中聊了关于如何提高 laravel 数据库测试性能,其实简单一句就是:每一个test case, 只重新 seed 被污染的表。 OK,这里有一个前提问题:那如何构建临时测试数据库呢?本文主要探讨如何构建临时测试数据库。

数据库设计图纸#

任何一个软件都需要数据库设计图纸,可以使用免费的 MySqlWorkbench 或者收费的 Navicat Data Modler 软件。这里使用免费的 MySqlWorkbench 来设计数据库图纸,类似下图:

图片描述

这里作为范例简单设计了 5 个 model,当然大型程序都会有 100 个以上 model。再利用软件的 Export SQL 功能导出数据库的 schema,这个 schema 文件就作为构建临时测试数据库的原料,schema 文件类似如下:

图片描述

临时数据库构建类#

在得到 schema 文件后,就可以写一个临时数据库构建类来创建临时测试数据库。这里临时表示该测试数据库使用完后即 drop 掉,且数据库名字是随机的,这样可以保证同时并发进行测试。需要先在 phpunit.xml 中指定数据库配置信息:

...
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="QUEUE_DRIVER" value="sync"/>
        <env name="DB_DATABASE" value="lx1036"/>
        <env name="DB_USERNAME" value="testing"/>
        <env name="DB_PASSWORD" value="testing"/>
    </php>
</phpunit>

然后在 config/database.php 中写上当运行测试时指定新构建的测试数据库:

'mysql' => [
            'driver' => 'mysql',
            'host' => env('DB_HOST', '127.0.0.1'),
            'port' => env('DB_PORT', '3306'),
            'database' => env('APP_ENV') === 'testing' ? \Tests\Database::getRandomDBName(env('DB_DATABASE', 'lx1036'), env('DB_HOST', 'localhost'), env('DB_USERNAME', 'root'), env('DB_PASSWORD')) : env('DB_DATABASE', 'forge'),
            'username' => env('DB_USERNAME', 'forge'),
            'password' => env('DB_PASSWORD', ''),
            'unix_socket' => env('DB_SOCKET', ''),
            'charset' => 'utf8mb4',
            'collation' => 'utf8mb4_unicode_ci',
            'prefix' => '',
            'strict' => true,
            'engine' => null,
        ],

然后写一个临时测试数据库构建类:

<?php

namespace Tests;

use PDO;

/**
 * Singleton class to enable parallel PHPUnit processes
 *
 * 1) Generate a random testing database with automatic destroy upon finish
 * 2) Initialize the database schemas using SQL file specified by constant SQL_PATH
 * 3) Remove orphan test databases
 */
class Database
{
    /** @var  \Tests\Database singleton to drop test database in destructor */
    protected static $instance;

    /** @var string */
    protected static $db_name;

    /** @var string */
    protected static $host;

    /** @var string */
    protected static $username;

    /** @var string */
    protected static $password;

    public function __construct(string $db_name)
    {
        static::$db_name = $db_name;
    }

    public function __destruct()
    {
        if (static::$db_name) {
            $pdo = new PDO('mysql:host=' . static::$host . ';' . 'dbname=' . static::$db_name, static::$username, static::$password);
            $pdo->exec('DROP DATABASE `' . static::$db_name . '`');
        }
    }

    public static function getRandomDBName(string $prefix, string $host, string $username, string $password, string $charset = 'utf8mb4', string $collation = 'utf8mb4_unicode_ci'): string
    {
        if (static::$instance) {
            return static::$instance->getDBName();
        }

        $db_name = $prefix . '_' . date('ymd') . '_' . str_random();

        $pdo = new PDO('mysql:host=' . $host, $username, $password);

        // Remove orphan database
        static::removeOrphans($pdo, $prefix);

        // Create random database
        $pdo->exec('CREATE DATABASE `' . $db_name . '` DEFAULT CHARACTER SET ' . $charset . ' COLLATE ' . $collation);
        $pdo->exec('USE `' . $db_name . '`');

        // Create tables in specified random database
        $schema_file = __DIR__ . '/../database/seeds/mysql.sql';

        if ($pdo->exec(file_get_contents($schema_file)) === false) {
            throw new \ErrorException("Cannot create tables by sql file: " . $schema_file . ' because of ' . $pdo->errorInfo()[2]);
        }

        /*
        // Check if tables are inserted.
        $result = $pdo->query("SHOW TABLES")->fetchAll(PDO::FETCH_NUM);
        dump($result);*/

        static::$instance = new static($db_name);
        static::$host     = $host;
        static::$username = $username;
        static::$password = $password;

        dump($db_name);
        return $db_name;
    }

    /**
     * Remove orphan database if exists.
     *
     * @param PDO $pdo
     * @param string $prefix
     */
    public static function removeOrphans(PDO $pdo, string $prefix)
    {
        $databases = $pdo->query('SHOW DATABASES LIKE "' . $prefix . '%"')->fetchAll();

        foreach ($databases as $database) {
            $database = reset($database);

            if (starts_with($database, $prefix) && is_numeric(explode('_', $database)[1])) {
                $pdo->exec('DROP DATABASE `' . $database . '`');

                echo 'Drop database ' . $database . PHP_EOL;
            }
        }
    }

    /**
     * @return string
     */
    public static function getDBName(): string
    {
        return static::$db_name;
    }

    /**
     * @return string
     */
    public static function getHost(): string
    {
        return static::$host;
    }

    /**
     * @return string
     */
    public static function getUsername(): string
    {
        return static::$username;
    }

    /**
     * @return string
     */
    public static function getPassword(): string
    {
        return static::$password;
    }
}

这样,当运行测试时连接的就是临时构建的测试数据库,测试运行完毕就 drop 掉数据库,并且可以同时开多个窗口 (线程) 来分组运行 test cases。最后还得在 mysql localhost 中创建 testing@testing 用户并授权,以 root 用户登录 local mysql

CREATE USER 'testing'@'localhost' IDENTIFIED BY 'testing';
GRANT ALL ON `lx1036%`.* TO 'testing'@'localhost';

这样就临时测试数据库就准备完毕了,然后就是 seed 测试数据,执行unit/feature tests, 执行assert等等,可以参考写 Laravel 测试代码 (一)。这里运行 phpunit 时得到的临时测试数据库是:

图片描述

OK,后续再聊执行 unit/feature tests 时一些实践技巧。

RightCapital 招聘 Laravel DevOps

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由 Summer 于 7年前 加精
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 13

为什么要使用单独的 SQL 文件来构建数据表呢?我之前写了迁移,在这里能用上吗

7年前 评论

@Dexter 你是说用 laravel migrations 来构建数据库的?建议最好能再加上数据库图纸。能用上,可以使用 migration 来构建临时数据库,不过不建议这么做。另外也不建议使用 model factory 来构建测试数据。

7年前 评论

@lx1036 :sob:是用这个的,毕竟结构改动升级什么的都很方便。测试数据我用了你写的测试(一)的方法填充,倒是没什么问题。只是我以前是用自带的事务来做测试,现在一测试就会重建数据,我就想测试的数据库要分开,刚好发现你的测试(二)就是讲这个。。不过我在 Database 里面调用不了 Artisan,提示有错,后来把创建 $app 的代码复制过来搞了挺久。。但是现在都还没弄好。。

7年前 评论

@Dexter 具体你是怎么做的呢,你可以写个提问文章,这样别人还能帮助看看。。或者你可以私信我也行,如果不方便贴代码的话。。

7年前 评论

@Dexter 你这个 getRandomDBName () 是在哪个 class 里,该 class 又继承了哪个 class,这些你得说清楚才行。另外看到这个 initApp () 我觉得不应该这么做,不需要重新启动 kernel,执行测试时 laravel 源码里会自己启动 application,你这么写那就是你代码放的地方有问题。

7年前 评论

@lx1036 Database 里面,除了把执行 sql 那两句注释掉换成调用 artisan,加上两个函数,其它都跟文中一样。我这两个函数就是测试启动时会跑的源码,是因为直接运行 Artisan 门面会提示 A facade root has not been set 我才复制过来的。。

7年前 评论

@Dexter 嗷嗷,明白你的意思了。。是 Database::class 这个类执行时 application 还未完全启动,不能使用 artisan 等。。你这里强行提前启动 application,然后 migrate 表,这样做也可以。。不过报的错误 Too many connections 应该不是你做法的问题,你看看你的 database.php 是不是哪里有什么问题。。

7年前 评论

为啥不建议用 ModelFactory 来构建数据,使用 https://github.com/laravel/framework/blob/... 这个 trait 就能创建临时数据了,这种方法有什么弊端吗?

7年前 评论

@Corrida 性能问题,另外不支持 nested transaction。

7年前 评论

@Corrida 等你的测试越来越多你就知道啥问题了。

7年前 评论

@lx1036 谢谢,可能我学的是假的 TDD?

7年前 评论

这里遇到个问题,如果数据库原本就有个只有前缀得一个数据库 static::removeOrphans ($pdo, $prefix); --》explode ('_', $database)[1] 就会报错

7年前 评论

改了下数据库查询

7年前 评论