|
| 1 | +本文主要探讨写数据库测试。 |
| 2 | + |
| 3 | +写 laravel 程序时,除了写生产代码,还需要写测试代码。其中,写数据库测试比较麻烦,因为需要针对每一个 test case 需要建立好数据集,该次 test case 污染的数据表还需要恢复现场,避免影响下一个 test case 运行,同时还得保证性能问题,否则随着程序不断膨胀,测试数量也越多,那每一次测试运行需要花费大量时间。 |
| 4 | + |
| 5 | +有两个比较好的方法可以提高数据库测试性能: |
| 6 | + |
| 7 | +1. 对大量的 `tests` 按照功能分组。如有 1000 个 tests,可以按照业务功能分组,如 `group1:1-200, group2:201-800, group3: 801-1000`。这样可以 `并发运行` 每组测试包裹。 |
| 8 | +2. 只恢复每个 `test case` 污染的表,而不需要把所有的数据表重新恢复,否则表数量越多测试代码执行越慢。 |
| 9 | +这里聊下方法2的具体做法。 |
| 10 | + |
| 11 | +假设程序有50张表,每次运行测试时首先需要为每组构建好独立的对应数据库,然后创建数据表,最后就是填充测试数据(`fixtures`)。`fixtures` 可用 `yml` 格式定义,既直观也方便维护,如: |
| 12 | + |
| 13 | +``` |
| 14 | +#simple.yml |
| 15 | +accounts: |
| 16 | + - id: 1 |
| 17 | + person_id: 2 |
| 18 | + type: investment |
| 19 | + is_included: true |
| 20 | + - id: 2 |
| 21 | + person_id: 2 |
| 22 | + type: investment |
| 23 | + is_included: true |
| 24 | +transactions: |
| 25 | + - account_id: 1 |
| 26 | + posted_date: '2017-01-01' |
| 27 | + amount: 10000 |
| 28 | + transaction_category_id: 1 |
| 29 | + - account_id: 2 |
| 30 | + posted_date: '2017-01-02' |
| 31 | + amount: 10001 |
| 32 | + transaction_category_id: 2 |
| 33 | +``` |
| 34 | + |
| 35 | +然后需要写个 `yamlSeeder class` 来把数据集填充到临时数据库里: |
| 36 | + |
| 37 | +``` |
| 38 | +abstract class YamlSeeder extends \Illuminate\Database\Seeder |
| 39 | +{ |
| 40 | + private $files; |
| 41 | +
|
| 42 | + public function __construct(array $files) |
| 43 | + { |
| 44 | + $this->files = $files |
| 45 | + } |
| 46 | +
|
| 47 | + public function run(array $tables = []): void |
| 48 | + { |
| 49 | + // Close unique and foreign key constraint |
| 50 | + $db = $this->container['db']; |
| 51 | + $db->statement('SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0;'); |
| 52 | + $db->statement('SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0;'); |
| 53 | +
|
| 54 | + foreach($this->files as $file) { |
| 55 | + ... |
| 56 | +
|
| 57 | + // Convert yaml data to array |
| 58 | + $fixtures = \Symfony\Component\Yaml\Yaml::parse(file_get_contents($file)); |
| 59 | +
|
| 60 | + ... |
| 61 | +
|
| 62 | + foreach($fixtures as $table => $data) { |
| 63 | + // Only seed specified tables, it is important!!! |
| 64 | + if ($tables && !in_array($table, $tables, true)) { |
| 65 | + continue; |
| 66 | + } |
| 67 | +
|
| 68 | + $db->table($table)->truncate(); |
| 69 | +
|
| 70 | + if (!$db->table($table)->insert($data)) { |
| 71 | + throw new \RuntimeException('xxx'); |
| 72 | + } |
| 73 | + } |
| 74 | +
|
| 75 | + ... |
| 76 | + } |
| 77 | +
|
| 78 | + // Open unique and foreign key constraint |
| 79 | + $db->statement('SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS;'); |
| 80 | + $db->statement('SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS;'); |
| 81 | + } |
| 82 | +} |
| 83 | +
|
| 84 | +class SimpleYamlSeeder extends YamlSeeder |
| 85 | +{ |
| 86 | + public function __construct() |
| 87 | + { |
| 88 | + parent::__construct([database_path('seeds/simple.yml')]); |
| 89 | + } |
| 90 | +} |
| 91 | +``` |
| 92 | + |
| 93 | +上面的代码有一个关键处是参数 `$tables`:如果参数是空数组,就把所有数据表数据插入随机数据库里;如果是指定的数据表,只重刷指定的数据表。这样会很大提高数据库测试的性能,因为可以在每一个 `test case` 里只需要指定本次测试所污染的数据表。在 `tests/TestCase.php` 中可以在 `setUp()` 设置数据库重装操作: |
| 94 | + |
| 95 | +``` |
| 96 | + abstract class TestCase extends \Illuminate\Foundation\Testing\TestCase |
| 97 | + { |
| 98 | + protected static $tablesToReseed = []; |
| 99 | +
|
| 100 | + public function seed($class = 'DatabaseSeeder', array $tables = []): void |
| 101 | + { |
| 102 | + $this->artisan('db:seed', ['--class' => $class, '--tables' => implode(',', $tables)]); |
| 103 | + } |
| 104 | +
|
| 105 | + protected function reseed(): void |
| 106 | + { |
| 107 | + // TEST_SEEDERS is defined in phpunit.xml, e.g. <env name="TEST_SEEDERS" value="\SimpleYamlSeeder"/> |
| 108 | + $seeders = env('TEST_SEEDERS') ? explode(',', env('TEST_SEEDERS')) : []; |
| 109 | +
|
| 110 | + if ($seeders && is_array(static::$tablesToReseed)) { |
| 111 | + foreach ($seeders as $seeder) { |
| 112 | + $this->seed($seeder, static::$tablesToReseed); |
| 113 | + } |
| 114 | + } |
| 115 | +
|
| 116 | + \Cache::flush(); |
| 117 | +
|
| 118 | + static::$tablesToReseed = false; |
| 119 | + } |
| 120 | +
|
| 121 | + protected static function reseedInNextTest(array $tables = []): void |
| 122 | + { |
| 123 | + static::$tablesToReseed = $tables; |
| 124 | + } |
| 125 | + } |
| 126 | +``` |
| 127 | + |
| 128 | +这样就可以在每一个 `test case` 中定义本次污染的数据表,保证下一个 `test case` 在运行前重刷下被污染的数据表,如: |
| 129 | + |
| 130 | + final class AccountControllerTest extends TestCase |
| 131 | + { |
| 132 | + ... |
| 133 | + |
| 134 | + public function testUpdateAccount() |
| 135 | + { |
| 136 | + static::reseedInNextTest([Account::TABLE, Transaction::TABLE]); |
| 137 | + |
| 138 | + ... |
| 139 | + } |
| 140 | + |
| 141 | + } |
| 142 | + |
| 143 | +这样会极大提高数据库测试效率,不推荐使用 Laravel 给出的 `\Illuminate\Foundation\Testing\DatabaseMigrations` 和 `\Illuminate\Foundation\Testing\DatabaseTransactions`,效率并不高。 |
| 144 | + |
| 145 | +laravel 的 `db:seed` 命令没有 `--tables` 这个 `options`,所以需要扩展 `\Illuminate\Database\Console\Seeds\SeedCommand`: |
| 146 | + |
| 147 | +``` |
| 148 | +class SeedCommand extends \Illuminate\Database\Console\Seeds\SeedCommand |
| 149 | +{ |
| 150 | + public function fire() |
| 151 | + { |
| 152 | + if (!$this->confirmToProceed()) { |
| 153 | + return; |
| 154 | + } |
| 155 | +
|
| 156 | + $this->resolver->setDefaultConnection($this->getDatabase()); |
| 157 | +
|
| 158 | + Model::unguarded(function () { |
| 159 | + $this->getSeeder()->run($this->getTables()); |
| 160 | + }); |
| 161 | + } |
| 162 | +
|
| 163 | + protected function getTables() |
| 164 | + { |
| 165 | + $tables = $this->input->getOption('tables'); |
| 166 | +
|
| 167 | + return $tables ? explode(',', $tables) : []; |
| 168 | + } |
| 169 | +
|
| 170 | + protected function getOptions() |
| 171 | + { |
| 172 | + $options = parent::getOptions(); |
| 173 | + $options[] = ['tables', null, InputOption::VALUE_OPTIONAL, 'A comma-separated list of tables to seed, all if left empty']; |
| 174 | +
|
| 175 | + return $options; |
| 176 | + } |
| 177 | +} |
| 178 | +``` |
| 179 | + |
| 180 | +当然还得写 `SeedServiceProvider()` 来覆盖原有的 `Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::registerSeedCommand()` 中注册的 `command.seed` ,然后在 `config/app.php` 中注册: |
| 181 | + |
| 182 | +``` |
| 183 | +class SeedServiceProvider extends ServiceProvider |
| 184 | +{ |
| 185 | + /** |
| 186 | + * Indicates if loading of the provider is deferred. |
| 187 | + * |
| 188 | + * @var bool |
| 189 | + */ |
| 190 | + protected $defer = true; |
| 191 | +
|
| 192 | + /** |
| 193 | + * @see \Illuminate\Database\SeedServiceProvider::registerSeedCommand() |
| 194 | + */ |
| 195 | + public function register() |
| 196 | + { |
| 197 | + $this->app->singleton('command.seed', function ($app) { |
| 198 | + return new SeedCommand($app['db']); |
| 199 | + }); |
| 200 | +
|
| 201 | + $this->commands('command.seed'); |
| 202 | + } |
| 203 | +
|
| 204 | + public function provides() |
| 205 | + { |
| 206 | + return ['command.seed']; |
| 207 | + } |
| 208 | +} |
| 209 | +``` |
| 210 | + |
| 211 | +OK,这样所有的工作都做完了。。以后写数据库测试性能会提高很多,大量的 `test case` 可以在短时间内运行完毕。 |
| 212 | + |
| 213 | +最后,写测试代码是必须的,好处非常多,随着项目程序越来越大,就会深深感觉到写测试是必须的,一劳永逸,值得花时间投资。也是作为一名软件工程师的必备要求。 |
| 214 | + |
| 215 | +引用地址: [写 Laravel 测试代码 (一)](https://learnku.com/articles/5053/write-the-laravel-test-code-1) |
0 commit comments