PHPUnit und Git Push zum automatisierten Vergleich von Datenbankschemata zwischen Entwicklungs- und Produktionsumgebung

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?

  1. SetUp-Methode: Die setUp()-Methode stellt Verbindungen zu den lokalen und entfernten Datenbanken her.
  2. Vergleich der Schemata: Die testCompareDatabaseSchemas()-Methode vergleicht die Tabellen und Spalten der lokalen und entfernten Datenbanken und sammelt alle Unterschiede.
  3. Fehlermeldungen: Am Ende des Tests wird überprüft, ob Fehler aufgetreten sind. Falls ja, werden alle Fehler ausgegeben, und der Test schlägt fehl.
  4. 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.