Skip to content

Commit

Permalink
Merge pull request #311 from hosteurope/feature/310_add_sqlite_runner
Browse files Browse the repository at this point in the history
#310 Implement new SqliteRunner which uses sqlite to dispatch tests.
  • Loading branch information
michaelgv authored Mar 29, 2018
2 parents ef38add + c17e323 commit d47a5c9
Show file tree
Hide file tree
Showing 16 changed files with 622 additions and 284 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Options:
--max-batch-size (-m) Max batch size (only for functional mode). (default: 0)
--filter Filter (only for functional mode).
--phpunit The PHPUnit binary to execute. (default: vendor/bin/phpunit)
--runner Runner or WrapperRunner. (default: Runner)
--runner Runner, WrapperRunner or SqliteRunner. (default: Runner)
--bootstrap The bootstrap file to be used by PHPUnit.
--configuration (-c) The PHPUnit configuration file to use.
--group (-g) Only runs tests from the specified group(s).
Expand All @@ -75,9 +75,9 @@ To get the most out of paratest, you have to adjust the parameters carefully.

Given you have few testcases (classes) with many long running methods, you should use the `-f` option to enable the `functional mode` and allow different methods of the same class to be executed in parallel. Keep in mind that the default is per-testcase-parallelization to address inter-testmethod dependencies. Note that in most projects, using `-f` is **slower** since each test **method** will need to be bootstrapped separately.

3. **Use the WrapperRunner if possible**
3. **Use the WrapperRunner or SqliteRunner if possible**

The default Runner for PHPUnit spawns a new process for each testcase (or method in functional mode). This provides the highest compatibility but comes with the cost of many spawned processes and a bootstrapping for each process. Especially when you have a slow bootstrapping in your tests (like a database setup) you should try the WrapperRunner with `--runner WrapperRunner`. It spawns one "worker"-process for each parallel process (`-p`), executes the bootstrapping once and reuses these processes for each test executed. That way the overhead of process spawning and bootstrapping is reduced to the minimum.
The default Runner for PHPUnit spawns a new process for each testcase (or method in functional mode). This provides the highest compatibility but comes with the cost of many spawned processes and a bootstrapping for each process. Especially when you have a slow bootstrapping in your tests (like a database setup) you should try the WrapperRunner with `--runner WrapperRunner` or the SqliteRunner with `--runner SqliteRunner`. It spawns one "worker"-process for each parallel process (`-p`), executes the bootstrapping once and reuses these processes for each test executed. That way the overhead of process spawning and bootstrapping is reduced to the minimum.

4. **Tune batch max size `--max-batch-size`**

Expand Down
41 changes: 41 additions & 0 deletions bin/phpunit-sqlite-wrapper
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php
if (!isset($argv[1])) {
fwrite(STDERR, 'First parameter for sqlite database file required.');
exit(1);
}

$db = new PDO('sqlite:' . $argv[1]);

// git working copy
if (file_exists(__DIR__ . '/../vendor/autoload.php')) {
require_once __DIR__ . '/../vendor/autoload.php';
}
// Composer installation
if (file_exists(__DIR__ . '/../../../autoload.php')) {
require_once __DIR__ . '/../../../autoload.php';
}

while ($test = $db->query('SELECT id, command FROM tests WHERE reserved_by_process_id IS NULL ORDER BY file_name LIMIT 1')->fetch()) {
$statement = $db->prepare('UPDATE tests SET reserved_by_process_id = :procId WHERE id = :id AND reserved_by_process_id IS NULL');
$statement->execute([
':procId' => getmypid(),
':id' => $test['id'],
]);

if ($statement->rowCount() !== 1) {
// Seems like this test has already been reserved. Continue to the next one.
continue;
}

try {
if (!preg_match_all('/\'([^\']*)\'[ ]?/', $test['command'], $arguments)) {
throw new \Exception("Failed to parse arguments from command line: \"" . $test['command'] . "\"");
}
$_SERVER['argv'] = $arguments[1];

PHPUnit\TextUI\Command::main(false);
} finally {
$db->prepare('UPDATE tests SET completed = 1 WHERE id = :id')
->execute([':id' => $test['id']]);
}
}
38 changes: 20 additions & 18 deletions src/Console/Testers/PHPUnit.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

namespace ParaTest\Console\Testers;

use InvalidArgumentException;
use ParaTest\Runners\PHPUnit\BaseRunner;
use ParaTest\Runners\PHPUnit\Configuration;
use ParaTest\Runners\PHPUnit\Runner;
use ParaTest\Runners\PHPUnit\WrapperRunner;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
Expand Down Expand Up @@ -35,7 +36,7 @@ public function configure(Command $command)
{
$command
->addOption('phpunit', null, InputOption::VALUE_REQUIRED, 'The PHPUnit binary to execute. <comment>(default: vendor/bin/phpunit)</comment>')
->addOption('runner', null, InputOption::VALUE_REQUIRED, 'Runner or WrapperRunner. <comment>(default: Runner)</comment>')
->addOption('runner', null, InputOption::VALUE_REQUIRED, 'Runner, WrapperRunner or SqliteRunner. <comment>(default: Runner)</comment>')
->addOption('bootstrap', null, InputOption::VALUE_REQUIRED, 'The bootstrap file to be used by PHPUnit.')
->addOption('configuration', 'c', InputOption::VALUE_REQUIRED, 'The PHPUnit configuration file to use.')
->addOption('group', 'g', InputOption::VALUE_REQUIRED, 'Only runs tests from the specified group(s).')
Expand Down Expand Up @@ -64,22 +65,7 @@ public function execute(InputInterface $input, OutputInterface $output)
$this->displayHelp($input, $output);
}

if ($input->getOption('runner') === 'WrapperRunner') {
$runner = new WrapperRunner($this->getRunnerOptions($input));
} else {
if ($input->getOption('runner') !== '') {
// because we want to have to bootstrap script inherited before check/initialization
$runnerOption = $this->getRunnerOptions($input);
$runnerClass = $input->getOption('runner');
if (null !== $runnerClass && class_exists($runnerClass)) {
$runner = new $runnerClass($runnerOption);
}
}
}

if (!isset($runner)) {
$runner = new Runner($this->getRunnerOptions($input));
}
$runner = $this->initializeRunner($input);

$runner->run();

Expand Down Expand Up @@ -236,4 +222,20 @@ protected function getBootstrapFile(InputInterface $input, array $options): stri

return ($bootstrap) ? $config->getConfigDir() . $bootstrap : '';
}

private function initializeRunner(InputInterface $input): BaseRunner
{
if ($input->getOption('runner')) {
$runnerClass = $input->getOption('runner') ?: '';
$runnerClass = class_exists($runnerClass) ? $runnerClass : ('\\ParaTest\\Runners\\PHPUnit\\' . $runnerClass);
} else {
$runnerClass = Runner::class;
}

if (!class_exists($runnerClass)) {
throw new InvalidArgumentException('Selected runner does not exist.');
}

return new $runnerClass($this->getRunnerOptions($input));
}
}
15 changes: 10 additions & 5 deletions src/Runners/PHPUnit/BaseRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ abstract class BaseRunner
* A collection of pending ExecutableTest objects that have
* yet to run.
*
* @var array
* @var ExecutableTest[]
*/
protected $pending = [];

Expand Down Expand Up @@ -67,10 +67,7 @@ public function __construct(array $opts = [])

public function run()
{
$this->verifyConfiguration();
$this->initCoverage();
$this->load(new SuiteLoader($this->options));
$this->printer->start($this->options);
$this->initialize();
}

/**
Expand Down Expand Up @@ -176,4 +173,12 @@ protected function getCoverage()
{
return $this->coverage;
}

protected function initialize(): void
{
$this->verifyConfiguration();
$this->initCoverage();
$this->load(new SuiteLoader($this->options));
$this->printer->start($this->options);
}
}
2 changes: 1 addition & 1 deletion src/Runners/PHPUnit/ExecutableTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ abstract class ExecutableTest
*
* @var string
*/
protected $lastCommand;
protected $lastCommand = '';

public function __construct(string $path, string $fullyQualifiedClassName = null)
{
Expand Down
152 changes: 152 additions & 0 deletions src/Runners/PHPUnit/SqliteRunner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php

declare(strict_types=1);

namespace ParaTest\Runners\PHPUnit;

use Exception;
use ParaTest\Runners\PHPUnit\Worker\SqliteWorker;
use PDO;
use RuntimeException;

class SqliteRunner extends WrapperRunner
{
/** @var PDO */
private $db;

/** @var string */
private $dbFileName = null;

public function __construct(array $opts = [])
{
parent::__construct($opts);

$this->dbFileName = (string)($opts['database'] ?? tempnam(sys_get_temp_dir(), 'paratest_db_'));
$this->db = new PDO('sqlite:' . $this->dbFileName);
}

public function run()
{
$this->initialize();

$this->createTable();
$this->assignAllPendingTests();
$this->startWorkers();
$this->waitForAllToFinish();
$this->complete();
$this->checkIfWorkersCrashed();
}

/**
* Start all workers.
*/
protected function startWorkers(): void
{
$wrapper = realpath(__DIR__ . '/../../../bin/phpunit-sqlite-wrapper');

for ($i = 1; $i <= $this->options->processes; ++$i) {
$worker = new SqliteWorker($this->dbFileName);
if ($this->options->noTestTokens) {
$token = null;
$uniqueToken = null;
} else {
$token = $i;
$uniqueToken = uniqid();
}
$worker->start($wrapper, $token, $uniqueToken);
$this->workers[] = $worker;
}
}

/**
* Wait for all workers to complete their tests and print output.
*/
private function waitForAllToFinish(): void
{
do {
foreach ($this->workers as $key => $worker) {
if (!$worker->isRunning()) {
unset($this->workers[$key]);
}
}
usleep(10000);
$this->printOutput();
} while (count($this->workers) > 0);
}

/**
* Initialize test queue table.
*
* @throws Exception
*/
private function createTable(): void
{
$statement = 'CREATE TABLE tests (
id INTEGER PRIMARY KEY,
command TEXT NOT NULL UNIQUE,
file_name TEXT NOT NULL,
reserved_by_process_id INTEGER,
completed INTEGER DEFAULT 0
)';

if ($this->db->exec($statement) === false) {
throw new Exception('Error while creating sqlite database table: ' . $this->db->errorCode());
}
}

/**
* Push all tests onto test queue.
*/
private function assignAllPendingTests(): void
{
foreach ($this->pending as $fileName => $test) {
$this->db->prepare('INSERT INTO tests (command, file_name) VALUES (:command, :fileName)')
->execute([
':command' => $test->command($this->options->phpunit, $this->options->filtered),
':fileName' => $fileName
]);
}
}

/**
* Loop through all completed tests and print their output.
*/
private function printOutput(): void
{
foreach ($this->db->query('SELECT id, file_name FROM tests WHERE completed = 1')->fetchAll() as $test) {
$this->printer->printFeedback($this->pending[$test['file_name']]);
$this->db->prepare('DELETE FROM tests WHERE id = :id')->execute([
'id' => $test['id']
]);
}
}

public function __destruct()
{
if ($this->db !== null) {
unset($this->db);
unlink($this->dbFileName);
}
}

/**
* Make sure that all tests were executed successfully.
*/
private function checkIfWorkersCrashed(): void
{
if ($this->db->query('SELECT COUNT(id) FROM tests')->fetchColumn(0) === "0") {
return;
}

throw new RuntimeException(
'Some workers have crashed.' . PHP_EOL
. '----------------------' . PHP_EOL
. 'All workers have quit, but some tests are still to be executed.' . PHP_EOL
. 'This may be the case if some tests were killed forcefully (for example, using exit()).' . PHP_EOL
. '----------------------' . PHP_EOL
. 'Failed test command(s):' . PHP_EOL
. '----------------------' . PHP_EOL
. implode(PHP_EOL, $this->db->query('SELECT command FROM tests')->fetchAll(PDO::FETCH_COLUMN))
);
}
}
Loading

0 comments on commit d47a5c9

Please sign in to comment.