Einführung
Wenn Sie in einer Entwicklungsumgebung arbeiten und Ihre Änderungen auf eine Produktionsumgebung übertragen, kann es leicht passieren, dass sich kleine, aber kritische Unterschiede in den Datenbankschemata einschleichen. Diese Unterschiede können zu Fehlern führen, die erst im laufenden Betrieb auffallen. Um solche Probleme zu vermeiden, ist es entscheidend, die Datenbankschemata zwischen der Entwicklungs- und der Produktionsumgebung vor einem Deployment zu vergleichen.
In diesem Artikel zeige ich Ihnen, wie Sie mit einer automatisierten PHPUnit-Testklasse sicherstellen können, dass die Datenbankschemata auf beiden Umgebungen identisch sind. Dieser Test wird in Ihrem CI/CD-Prozess integriert und verhindert so den Push, wenn ein Fehler gefunden wird.
Warum ist der Vergleich von Datenbankschemata wichtig?
Ich selbst vergesse regelmäßig, die Datenbank auf der Produktionsumgebung nachzuziehen – was besonders schlimm ist, wenn es im laufenden Betrieb nicht gleich auffällt.
Erstellen eines PHPUnit-Tests zum Vergleich der Datenbankschemata
Wenn Sie PHPUnit noch nicht installiert haben, können Sie es über Composer installieren. Führen Sie den folgenden Befehl im Stammverzeichnis des Projekts aus:
composer require --dev phpunit/phpunit
Um die Datenbankschemata zu vergleichen, erstellen wir einen PHPUnit-Test, der die Tabellen und Spalten in den lokalen und den Remote-Datenbanken überprüft. Dieser Test sammelt alle Fehler und gibt sie gesammelt am Ende des Tests aus. Wir erstellen die Datei tests/DatabaseSchemaTest.php. Der Code sollte selbsterklärend sein.
In PHP-Projekten ist es üblich, Tests im Verzeichnis tests
zu organisieren. Der Name der Datei sollte auf .php
enden und den Namen der Testklasse widerspiegeln.
<?php
// tests/DatabaseSchemaTest.php
// CLI: vendor/bin/phpunit --filter DatabaseSchemaTest
namespace App\Tests;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\DriverManager;
use PHPUnit\Framework\TestCase;
class DatabaseSchemaTest extends TestCase
{
private $localDb1Connection;
private $remoteDb1Connection;
private $localDb2Connection;
private $remoteDb2Connection;
protected function setUp(): void
{
$this->localDb1Connection = DriverManager::getConnection(['url' => 'mysql://user:pass@localhost/local_db1']);
$this->remoteDb1Connection = DriverManager::getConnection(['url' => 'mysql://user:pass@remote_host/remote_db1']);
$this->localDb2Connection = DriverManager::getConnection(['url' => 'mysql://user:pass@localhost/local_db2']);
$this->remoteDb2Connection = DriverManager::getConnection(['url' => 'mysql://user:pass@remote_host/remote_db2']);
}
public function testCompareDatabaseSchemas(): void
{
$errors = [];
$this->compareSchemas($this->localDb1Connection, $this->remoteDb1Connection, 'Local DB1', 'Remote DB1', $errors);
$this->compareSchemas($this->localDb2Connection, $this->remoteDb2Connection, 'Local DB2', 'Remote DB2', $errors);
if (!empty($errors)) {
$this->fail("Schema comparison errors:\n" . implode("\n", $errors));
}
$this->assertEmpty($errors, "Schema comparison passed without errors.");
}
private function compareSchemas($localConnection, $remoteConnection, string $localDbName, string $remoteDbName, array &$errors): void
{
$localSchemaManager = $localConnection->createSchemaManager();
$remoteSchemaManager = $remoteConnection->createSchemaManager();
$localTables = $localSchemaManager->listTableNames();
$remoteTables = $remoteSchemaManager->listTableNames();
foreach ($localTables as $localTableName) {
if (str_ends_with($localTableName, '_pv')) {
continue; // Ignoriere Tabellen, die auf '_pv' enden
}
if (!in_array($localTableName, $remoteTables)) {
$errors[] = "Table '$localTableName' exists in $localDbName but not in $remoteDbName.";
continue;
}
$localTable = $localSchemaManager->introspectTable($localTableName);
$remoteTable = $remoteSchemaManager->introspectTable($localTableName);
$localColumns = $localTable->getColumns();
$remoteColumns = $remoteTable->getColumns();
foreach ($localColumns as $localColumn) {
$columnName = $localColumn->getName();
if (!isset($remoteColumns[$columnName])) {
$errors[] = "Column '$columnName' in table '$localTableName' exists in $localDbName but not in $remoteDbName.";
continue;
}
$localType = get_class($localColumn->getType());
$remoteType = get_class($remoteColumns[$columnName]->getType());
if ($localType !== $remoteType) {
$errors[] = "Column type mismatch for '$columnName' in table '$localTableName' between $localDbName and $remoteDbName. Expected type: $localType, but got: $remoteType.";
}
}
foreach ($remoteColumns as $remoteColumn) {
$columnName = $remoteColumn->getName();
if (!isset($localColumns[$columnName])) {
$errors[] = "Column '$columnName' in table '$localTableName' exists in $remoteDbName but not in $localDbName.";
}
}
}
foreach ($remoteTables as $remoteTableName) {
if (!in_array($remoteTableName, $localTables)) {
$errors[] = "Table '$remoteTableName' exists in $remoteDbName but not in $localDbName.";
}
}
}
}
project-root/ ├── src/ │ └── … (Hauptcode) ├── tests/ │ └── DatabaseSchemaTest.php ├── vendor/ │ └── … (Composer-Abhängigkeiten) ├── composer.json └── phpunit.xml
PHPUnit-Konfiguration
Stellen Sie sicher, dass Sie eine phpunit.xml
-Datei im Stammverzeichnis Ihres Projekts haben, die PHPUnit für das Ausführen der Tests konfiguriert. Eine einfache Konfiguration könnte so aussehen:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php">
<testsuites>
<testsuite name="Application Test Suite">
<directory>./tests</directory>
</testsuite>
</testsuites>
</phpunit>
Anpassen der bootstrap.php
Die Datei bootstrap.php im Ordner tests muss angepasst werden, damit der Autoloader für Composer-Pakete gefunden wird.
<?php
use Symfony\Component\Dotenv\Dotenv;
require dirname(__DIR__).'/vendor/autoload.php'; // Autoloader für Composer-Pakete
if (file_exists(dirname(__DIR__).'/config/bootstrap.php')) {
require dirname(__DIR__).'/config/bootstrap.php';
} elseif (method_exists(Dotenv::class, 'bootEnv')) {
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
}
Manuelles Ausführen des Tests
Um den Test auszuführen, können Sie den folgenden Befehl im Terminal im Stammverzeichnis des Projekts verwenden:
vendor/bin/phpunit tests/DatabaseSchemaTest.php
Wie funktioniert das?
- SetUp-Methode: Die
setUp()
-Methode stellt Verbindungen zu den lokalen und entfernten Datenbanken her. - Vergleich der Schemata: Die
testCompareDatabaseSchemas()
-Methode vergleicht die Tabellen und Spalten der lokalen und entfernten Datenbanken und sammelt alle Unterschiede. - Fehlermeldungen: Am Ende des Tests wird überprüft, ob Fehler aufgetreten sind. Falls ja, werden alle Fehler ausgegeben, und der Test schlägt fehl.
- Integration in CI/CD: Diese Tests können in Ihre CI/CD-Pipeline integriert werden (siehe weiter unten). Wenn der Test fehlschlägt, wird der Deployment-Prozess gestoppt.
Hier ist eine kurze Erklärung der verschiedenen Zeichen, die PHPUnit verwendet:
- “.” (Punkt): Ein Testfall wurde erfolgreich abgeschlossen.
- “E” (Error): Ein Testfall hat einen Fehler verursacht, z.B. eine Ausnahme, die nicht abgefangen wurde.
- “F” (Failure): Ein Testfall hat eine Assertion nicht bestanden.
- “R” (Risky): Der Testfall wurde als riskant eingestuft, weil er z.B. keine Assertions enthält.
- “S” (Skipped): Ein Testfall wurde übersprungen, z.B. aufgrund einer bestimmten Bedingung.
- “I” (Incomplete): Ein Testfall ist unvollständig, z.B. weil er noch nicht vollständig implementiert ist.
Einbinden in Git push
Um sicherzustellen, dass ein git push
nur dann ausgeführt wird, wenn der DatabaseSchemaTest
erfolgreich durchläuft, sollte man einen Git-Hook verwenden. Git-Hooks sind Skripte, die Git automatisch bei bestimmten Ereignissen im Lebenszyklus eines Repositories ausführt. Der pre-push
Hook wird unmittelbar vor dem Push-Vorgang ausgeführt und kann verwendet werden, um Tests zu starten und den Push-Vorgang bei einem Fehler zu verhindern.
Im Root-Verzeichnis des Git-Repositories gibt es einen versteckten Ordner .git/hooks
. Erstellen Sie darin eine Datei namens pre-push
(ohne Dateierweiterung), falls diese bisher nicht existiert.
touch .git/hooks/pre-push
chmod +x .git/hooks/pre-push
Öffnen Sie die pre-push
Datei in einem Texteditor und fügen Sie folgendes Skript hinzu:
#!/bin/sh
# Führe PHPUnit Tests aus
./vendor/bin/phpunit --filter DatabaseSchemaTest
# Überprüfe den Exit-Code von PHPUnit
if [ $? -ne 0 ]; then
echo "DatabaseSchemaTest failed. Push aborted."
exit 1
fi
Dieses Skript führt den DatabaseSchemaTest
aus. Wenn der Test fehlschlägt (d.h. PHPUnit gibt einen Exit-Code ungleich 0 zurück), wird der Push-Vorgang abgebrochen.
Ich habe hier mal einen Fehler simuliert bzw. absichtlich erzeugt.
Integrieren Sie diesen Test in Ihre CI/CD-Pipeline, um sicherzustellen, dass Sie nie wieder aufgrund von Datenbankunterschieden in der Produktion überrascht werden.