Adminhtml-Controller in Magento mit PHPUnit absichern
AI generated
@test
assert
PHPUnit · Magento · Adminhtml
Adminhtml-Controller in Magento mit PHPUnit absichern
ACL-Pruefungen, Formularvalidierung und Redirect-Logik gezielt testen

Adminhtml-Controller vereinen Zugriffskontrolle, Formularverarbeitung und Redirect-Logik an einer Stelle, weshalb sich Unit- und Integrationstests ergaenzen muessen, um sowohl die reine Logik als auch das Zusammenspiel mit dem Magento-Backend-Stack abzudecken.

15 Min. Lesezeit Adminhtml ACL Backend Integrationstest

1. Warum Adminhtml-Controller eine besondere Testebene brauchen

Ein Adminhtml-Controller in Magento erbt in der Regel von Magento\Backend\App\Action und buendelt mehrere Verantwortlichkeiten: die ACL-Pruefung ueber die _isAllowed()-Methode, das Einlesen und Validieren von Formulardaten aus dem Request, das Aufrufen der eigentlichen Geschaeftslogik sowie das Setzen von Success- oder Error-Messages und die Redirect-Entscheidung. Diese Buendelung macht den Controller zu einem naturgemaess schwerer isolierbaren Baustein als eine reine Service-Klasse, weil mehrere Framework-Schichten gleichzeitig im Spiel sind und ein einzelner Test schnell zu viel auf einmal pruefen moechte.

Deshalb lohnt sich hier ein zweistufiger Test-Ansatz: Ein reiner Unit-Test prueft die Entscheidungslogik innerhalb der execute()-Methode mit gemocktem Request, Response und Session, waehrend ein Magento-Integrationstest, der von \Magento\TestFramework\TestCase\AbstractBackendController erbt, das tatsaechliche Routing, die ACL-Aufloesung ueber die echte Berechtigungsstruktur und das komplette Dispatching prueft. Beide Ebenen ergaenzen sich, statt sich zu ersetzen, und zusammen ergeben sie ein vollstaendiges Bild davon, ob ein Controller sowohl fachlich korrekt als auch sicherheitstechnisch abgesichert ist.

2. Einen testbaren Adminhtml-Controller strukturieren

Damit ein Controller ueberhaupt sinnvoll unit-testbar ist, sollte die eigentliche Verarbeitung der Formulardaten nicht direkt in execute() stattfinden, sondern an einen injizierten Service delegiert werden. Der Controller selbst bleibt fuer das Auslesen der Request-Parameter, das Aufrufen des Service und die Entscheidung ueber die Antwort zustaendig. Das ist dasselbe Prinzip wie bei Cronjobs und Queue-Consumern: duenne Eingangsschicht, ausgelagerte Fachlogik.

Zusaetzlich sollte die ACL-Ressource explizit als Konstante definiert und in _isAllowed() referenziert werden, statt den String direkt zu hardcoden. Das erleichtert es, in einem Test gezielt zu pruefen, welche Ressource fuer den Controller erforderlich ist, und verhindert Tippfehler zwischen der Controller-Klasse und der acl.xml.


<?php
declare(strict_types=1);

namespace Mironsoft\SeoSuite\Controller\Adminhtml\Redirect;

use Magento\Backend\App\Action;
use Magento\Framework\Controller\ResultFactory;
use Mironsoft\SeoSuite\Model\RedirectSaver;

/**
 * Speichert eine neue Weiterleitungsregel im Adminbackend.
 */
class Save extends Action
{
    public const ADMIN_RESOURCE = 'Mironsoft_SeoSuite::redirect_save';

    public function __construct(
        Action\Context $context,
        private readonly RedirectSaver $redirectSaver
    ) {
        parent::__construct($context);
    }

    /**
     * Verarbeitet die Formulardaten und speichert die Weiterleitung.
     *
     * @return \Magento\Framework\Controller\ResultInterface
     */
    public function execute()
    {
        $data = $this->getRequest()->getParams();
        $resultRedirect = $this->resultFactory->create(ResultFactory::TYPE_REDIRECT);

        if (empty($data['from_path']) || empty($data['to_path'])) {
            $this->messageManager->addErrorMessage(__('Bitte alle Pflichtfelder ausfuellen.'));
            return $resultRedirect->setPath('*/*/new');
        }

        $this->redirectSaver->save($data['from_path'], $data['to_path']);
        $this->messageManager->addSuccessMessage(__('Weiterleitung wurde gespeichert.'));

        return $resultRedirect->setPath('*/*/');
    }
}

3. Die execute-Logik als Unit-Test mit gemocktem Request

Fuer den reinen Unit-Test wird der Controller nicht ueber das Dispatching aufgerufen, sondern direkt instanziiert, wobei Context, Request, MessageManager und der injizierte Service gemockt werden. Der Test prueft dann, ob bei fehlenden Pflichtfeldern die Fehlermeldung gesetzt und zur richtigen Seite zurueckgeleitet wird, ohne dass der Speicher-Service ueberhaupt aufgerufen wird.

Dieser Test laeuft ohne Datenbank und ohne echtes HTTP-Routing in Millisekunden. Er deckt die eigentliche Entscheidungslogik ab: Welche Bedingungen fuehren zu welchem Redirect, welche Message wird gesetzt, wird der Service korrekt mit den erwarteten Parametern aufgerufen. Das ist die gleiche Teststrategie wie bei jeder anderen Service-Klasse, nur mit den controller-spezifischen Mocks fuer Request und MessageManager.


<?php
declare(strict_types=1);

namespace Mironsoft\SeoSuite\Test\Unit\Controller\Adminhtml\Redirect;

use Mironsoft\SeoSuite\Controller\Adminhtml\Redirect\Save;
use Mironsoft\SeoSuite\Model\RedirectSaver;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Message\ManagerInterface;
use Magento\Framework\Controller\Result\Redirect;
use Magento\Framework\Controller\ResultFactory;
use PHPUnit\Framework\TestCase;

class SaveTest extends TestCase
{
    public function testMissingRequiredFieldsShowsErrorAndRedirectsToNew(): void
    {
        $request = $this->createMock(RequestInterface::class);
        $request->method('getParams')->willReturn(['from_path' => '', 'to_path' => '']);

        $messageManager = $this->createMock(ManagerInterface::class);
        $messageManager->expects($this->once())->method('addErrorMessage');

        $redirectResult = $this->createMock(Redirect::class);
        $redirectResult->expects($this->once())->method('setPath')->with('*/*/new')->willReturnSelf();

        $resultFactory = $this->createMock(ResultFactory::class);
        $resultFactory->method('create')->willReturn($redirectResult);

        $context = $this->createMock(Context::class);
        $context->method('getRequest')->willReturn($request);
        $context->method('getMessageManager')->willReturn($messageManager);
        $context->method('getResultFactory')->willReturn($resultFactory);

        $redirectSaver = $this->createMock(RedirectSaver::class);
        $redirectSaver->expects($this->never())->method('save');

        $controller = new Save($context, $redirectSaver);
        $controller->execute();
    }
}

4. ACL-Pruefungen als Integrationstest gegen die echte Berechtigungsstruktur

Waehrend der Unit-Test die Kernlogik prueft, laesst sich die tatsaechliche ACL-Durchsetzung nur sinnvoll gegen die echte Magento-Berechtigungsstruktur testen, weil dort geprueft wird, ob ein Benutzer mit einer bestimmten Rolle Zugriff auf die konfigurierte Ressource aus acl.xml erhaelt oder mit einer 403-Antwort abgewiesen wird. Dafuer bietet Magento die Basisklasse AbstractBackendController im TestFramework an, die Dispatching, Session und Autorisierung realistisch nachbildet.

Ein solcher Integrationstest simuliert einen Admin-Benutzer ohne die erforderliche Ressource und prueft, dass der Zugriff verweigert wird, sowie einen Benutzer mit der Ressource, bei dem die Aktion erfolgreich durchlaeuft. Dieser Test ist deutlich langsamer als ein Unit-Test, weil er den kompletten Magento-Bootstrap benoetigt, deckt dafuer aber genau die Stelle ab, an der ein reiner Unit-Test blind waere: das Zusammenspiel von Controller, acl.xml und Rollenverwaltung.


<?php
declare(strict_types=1);

namespace Mironsoft\SeoSuite\Test\Integration\Controller\Adminhtml\Redirect;

use Magento\TestFramework\TestCase\AbstractBackendController;

/**
 * @magentoAppArea adminhtml
 */
class SaveTest extends AbstractBackendController
{
    /**
     * @magentoConfigFixture current_store admin/security/admin_account_sharing 0
     */
    public function testAclHasAccess(): void
    {
        $this->dispatch('backend/mironsoft_seosuite/redirect/save');
        $this->assertSessionMessages($this->equalTo([]));
    }

    public function testAclNoAccess(): void
    {
        $this->getRequest()->setParam('form_key', $this->_objectManager->get(
            \Magento\Framework\Data\Form\FormKey::class
        )->getFormKey());
        $this->uri = 'backend/mironsoft_seosuite/redirect/save';
        parent::testAclNoAccess();
    }
}

5. Formularvalidierung als eigenstaendige, isolierte Regel testen

Ist die Validierungslogik komplexer als eine simple Leerstring-Pruefung, etwa eine Regel, dass from_path mit einem Slash beginnen und nicht mit der to_path identisch sein darf, lohnt es sich, diese Validierung in eine eigene Validator-Klasse auszulagern. Der Controller ruft dann lediglich validate() auf und reagiert auf das Ergebnis, waehrend die eigentliche Regelmenge in einem eigenen, feingranularen Test-Set liegt.

Diese Trennung erlaubt es, viele Validierungsvarianten ueber einen Data Provider durchzuspielen, ohne jedes Mal den kompletten Controller-Kontext aufzubauen. Aendert sich eine Validierungsregel, muss nur der Validator-Test angepasst werden, waehrend der Controller-Test unveraendert bleibt, solange die Schnittstelle zum Validator gleich bleibt.


<?php
declare(strict_types=1);

/**
 * @dataProvider invalidRedirectDataProvider
 */
public function testValidatorRejectsInvalidRedirectRules(string $from, string $to, string $expectedError): void
{
    $validator = new RedirectRuleValidator();
    $result = $validator->validate($from, $to);

    $this->assertFalse($result->isValid());
    $this->assertSame($expectedError, $result->getFirstError());
}

public static function invalidRedirectDataProvider(): array
{
    return [
        'missing leading slash' => ['catalog/product', '/new-path', 'from_path must start with a slash'],
        'identical paths' => ['/old-path', '/old-path', 'from_path and to_path must differ'],
    ];
}

6. Redirect-Ziele und Message-Typen praezise pruefen

Ein haeufiger Bug in Adminhtml-Controllern ist ein falsches Redirect-Ziel nach dem Speichern, etwa wenn nach dem Klick auf Speichern-und-Weiter statt zur Bearbeitungsseite versehentlich zur Listenansicht weitergeleitet wird. Solche Fehler faellt im manuellen Testen oft nicht sofort auf, weil beide Seiten valide Antworten liefern, nur eben die falsche im jeweiligen Kontext.

Ein gezielter Unit-Test, der fuer jeden Request-Parameter, etwa den Save-and-Continue-Button, das erwartete Redirect-Ziel prueft, deckt genau diese Klasse von Fehlern zuverlaessig auf. Kombiniert mit einer Ueberpruefung des Message-Typs, ob also tatsaechlich addSuccessMessage statt addErrorMessage aufgerufen wurde, ergibt sich eine praezise Spezifikation des sichtbaren Nutzerverhaltens.


<?php
declare(strict_types=1);

public function testSaveAndContinueRedirectsToEditPageWithId(): void
{
    $request = $this->createMock(RequestInterface::class);
    $request->method('getParams')->willReturn([
        'from_path' => '/old', 'to_path' => '/new', 'back' => 'edit', 'entity_id' => '5',
    ]);

    $redirectResult = $this->createMock(Redirect::class);
    $redirectResult->expects($this->once())->method('setPath')->with('*/*/edit', ['id' => '5'])->willReturnSelf();

    // ... Context-Setup analog zum vorherigen Test ...
    $this->assertTrue(true);
}

7. Abgrenzung zu vollstaendigen Functional- und MFTF-Tests

PHPUnit-Tests, ob als Unit- oder Integrationstest, pruefen immer PHP-seitiges Verhalten: Wird die richtige Methode aufgerufen, wird das richtige Redirect gesetzt, stimmt die ACL-Konfiguration. Was PHPUnit bewusst nicht abdeckt, ist das tatsaechliche Rendering der Adminhtml-Oberflaeche im Browser, JavaScript-Interaktionen im UI-Component-Formular oder das visuelle Layout der Seite.

Fuer diese Aspekte ist MFTF, das Magento Functional Testing Framework, die richtige Ebene, weil es einen echten Browser steuert und den kompletten Stack von HTML ueber JavaScript bis zur Datenbank durchlaeuft. In der Praxis bewaehrt sich eine klare Aufgabenteilung: PHPUnit deckt die Logikfaelle mit vielen kleinen, schnellen Tests ab, waehrend MFTF wenige, aber kritische End-to-End-Pfade wie das erfolgreiche Anlegen eines Datensatzes ueber die Oberflaeche absichert.

8. Haeufige Testfallen bei Adminhtml-Controllern

Eine haeufige Falle ist, den ObjectManager direkt im Controller zu verwenden, um Abhaengigkeiten zu erzeugen, statt sie ueber den Konstruktor zu injizieren. Das macht den Controller im Unit-Test praktisch nicht mockbar, weil die tatsaechlich verwendete Instanz erst zur Laufzeit im echten Container aufgeloest wird. Jede Abhaengigkeit sollte deshalb explizit im Konstruktor deklariert werden, auch wenn das bei Controllern mit vielen Instanzvariablen zunaechst umstaendlicher wirkt und die Konstruktor-Signatur laenger macht als bei einer schlanken Service-Klasse.

Eine zweite haeufige Falle ist, in Tests versehentlich echte Session- oder Cookie-Objekte zu verwenden, statt sie zu mocken, was zu unvorhersehbarem Verhalten fuehrt, je nachdem, welcher Zustand von einem vorherigen Test uebrig geblieben ist. Konsequentes Mocking aller Backend-App-Context-Abhaengigkeiten verhindert solche schwer reproduzierbaren Testfehler zuverlaessig. Eine dritte, oft unterschaetzte Falle ist ein zu grosszuegig formulierter Test, der nur prueft, dass ueberhaupt irgendein Redirect zurueckgegeben wird, statt das konkrete Ziel und die konkrete Message zu verifizieren, wodurch echte Regressionen unbemerkt durchrutschen koennen.

9. Checkliste und Testebenen im Ueberblick

Wer neue Adminhtml-Controller entwickelt, sollte von Beginn an Fachlogik und Validierung in eigene Services auslagern, ACL-Konstanten explizit definieren und fuer jede execute-Verzweigung einen gezielten Unit-Test schreiben. Die ACL-Durchsetzung selbst gehoert in einen schlanken Integrationstest, waehrend das visuelle Verhalten der Oberflaeche MFTF vorbehalten bleibt.

Die folgende Tabelle ordnet die verschiedenen Testebenen rund um Adminhtml-Controller ein und zeigt, welche Ebene welchen Aspekt des Controllers abdeckt.

Testebene Was geprueft wird Benoetigt Magento-Bootstrap Typische Ausfuehrungszeit
Controller-Unit-Test execute-Logik, Redirects, Messages mit gemocktem Request Nein Millisekunden
Validator-Unit-Test Formularregeln unabhaengig vom Controller Nein Millisekunden
ACL-Integrationstest Zugriffsdurchsetzung gegen echte Rollen und acl.xml Ja Sekunden
MFTF-Functional-Test Rendering, JavaScript, kompletter Nutzerpfad im Browser Ja (inkl. Browser) Minuten

Mironsoft

Testautomatisierung, Magento-Qualitätssicherung und CI-Integration

Tests, die echte Fehler finden statt nur grün zu leuchten?

Wir prüfen bestehende PHPUnit-Suiten auf Implementierungsdetail-Tests, flaky Tests und fehlende Coverage an kritischen Stellen und bauen daraus eine Teststrategie, die bei jedem Magento-Update wirklich Sicherheit gibt.

Test-Audit

Bestehende Suiten auf Mocking-Antipatterns und blinde Flecken prüfen.

Teststrategie

Unit-, Integrations- und MFTF-Tests sinnvoll für Magento-Projekte kombinieren.

CI-Integration

Schnelle, zuverlässige Testläufe in GitLab CI oder GitHub Actions einrichten.

10. Zusammenfassung

Adminhtml-Controller testen: Das Wichtigste auf einen Blick

Zwei Ebenen

Unit-Test fuer execute-Logik, Integrationstest fuer echte ACL-Durchsetzung

Duenne Controller

Fachlogik und Validierung in eigene Services und Validator-Klassen auslagern

ACL-Konstanten

ADMIN_RESOURCE explizit definieren, um Tippfehler zwischen Code und acl.xml zu vermeiden

Klare Grenze

MFTF fuer Rendering und Browser-Interaktion, PHPUnit fuer PHP-seitige Logik

11. FAQ: Adminhtml-Controller testen: Das Wichtigste auf einen Blick

1Reicht ein Unit-Test aus, um einen Adminhtml-Controller vollstaendig abzusichern?
Nein, ein Unit-Test prueft nur die execute-Logik mit gemockten Abhaengigkeiten. Die tatsaechliche ACL-Durchsetzung muss zusaetzlich in einem Integrationstest gegen die echte Berechtigungsstruktur geprueft werden.
2Wie teste ich, dass ein Controller ohne die richtige ACL-Ressource abgewiesen wird?
Mit einem Integrationstest, der von AbstractBackendController erbt und einen Benutzer ohne die erforderliche Ressource simuliert, wobei eine 403-Antwort erwartet wird.
3Muss die Formularvalidierung im Controller selbst stattfinden?
Nein, komplexere Validierungsregeln sollten in eine eigene Validator-Klasse ausgelagert werden, die unabhaengig vom Controller-Kontext mit einem Data Provider getestet werden kann.
4Wie teste ich, dass die richtige Success- oder Error-Message gesetzt wird?
Der MessageManager wird gemockt und es wird geprueft, dass addSuccessMessage oder addErrorMessage je nach Szenario genau einmal mit der erwarteten Nachricht aufgerufen wird.
5Was ist der Unterschied zwischen einem Unit-Test und einem MFTF-Test fuer denselben Controller?
Der Unit-Test prueft PHP-seitige Logik ohne Browser und Datenbank, waehrend MFTF das tatsaechliche Rendering, JavaScript-Interaktionen und den kompletten Nutzerpfad im echten Browser absichert.
6Wie vermeide ich, dass der ObjectManager Tests unmockbar macht?
Alle Abhaengigkeiten sollten explizit ueber den Konstruktor injiziert werden, statt sie per ObjectManager innerhalb der execute-Methode zu erzeugen.
7Lohnt sich ein Integrationstest fuer jeden einzelnen Adminhtml-Controller?
Nicht zwingend fuer jeden, aber fuer Controller mit sicherheitskritischen Aktionen wie dem Loeschen von Daten ist ein ACL-Integrationstest empfehlenswert, waehrend einfache Controller oft mit einem Unit-Test ausreichend abgedeckt sind.
8Wie teste ich unterschiedliche Redirect-Ziele je nach Button-Klick?
Fuer jeden moeglichen Request-Parameter, etwa den Save-and-Continue-Button, wird ein eigener Testfall geschrieben, der das erwartete Redirect-Ziel gegen das gemockte Result-Objekt prueft.
9Warum sollte die ACL-Ressource als Konstante definiert werden?
Damit sie in Tests referenziert und gegen die acl.xml abgeglichen werden kann, ohne dass sich Tippfehler zwischen Code und Konfiguration unbemerkt einschleichen.
10Wie gehe ich mit Session- oder Cookie-Abhaengigkeiten in Controller-Tests um?
Diese sollten konsequent gemockt werden, um zu verhindern, dass Zustand aus vorherigen Tests das Ergebnis beeinflusst und schwer reproduzierbare Testfehler entstehen.