Magento: Singletons mit Mock-Objekten/Stubs ersetzen

Und wieder etwas zum Thema Unit Test mit Magento:

Es ist möglich, Models die über Mage::getSingleton() geladen werden zur Laufzeit mit konkreten Objekten zu ersetzen, quasi der Magento way of Dependency Injection. Dazu ist allerdings ein kleiner Trick nötig. Es gibt zwar auch die Möglichkeit, Rewrites zur Laufzeit zu konfigurieren aber im Fall von Mock Objects hilft uns das nicht weiter, schließlich wollen wir Magento nicht einfach eine andere Klasse instantiieren lassen sondern einen fertig konfigurierten Stub injizieren.

Hierzu ein gekürztes Beispiel (Test Case):

//...
	const LIFE_CLASS = 'My_Namespace_Model_Life';
	const LIFE_MODEL = 'mynamespace/life';

	private $lifeStub;
//...
	public function setUp()
	{
		parent::setUp();
		$this->registerLifeStub();
	}
	/**
	 * @test
	 */
	public function testUseBackendModel()
	{
		$this->configureLifeStub(42);
		// now test something that uses Life
	}
//...
	private function registerLifeStub()
	{
		$this->lifeStub = $this->getMock(self::LIFE_CLASS);
			
		// register stub as singleton
		$registryKey = '_singleton/'.self::LIFE_MODEL;
		Mage::unregister($registryKey);
		Mage::register($registryKey, $this->lifeStub);
	}
	private function configureLifeStub($result)
	{
		$this->lifeStub
			->expects($this->any())
			->method('getMeaning')
			->will($this->returnValue($result));
	}
//...

Die Besonderheit liegt hier in der Methode registerLifeStub(), die beim Setup aufgerufen wird, das Mock Object anlegt und registriert. Dabei wird direkt so auf die Magento-Registry zugegriffen wie es Mage::getSingleton() macht. Wichtig ist auch der Aufruf von Mage::unregister() da Mage::register() kein Überschreiben zulässt.

configureLifeStub() ist nur ein Beispiel dafür dass das Mock Object jederzeit unterschiedlich konfiguriert werden kann und dies nicht beim erstellen festgelegt werden muss.

Diese Methode ist sowohl für Core-Singletons wie die diversen Session-Klassen als auch für selbst geschriebene Provider-Klassen interessant, funktioniert aber wirklich nur, wenn diese auch mit Mage.:getSingleton() geladen werden, da Mage::getModel() immer ein neues Objekt instantiiert – dafür wäre ein deutlich tieferer Eingriff notwendig.

Magento: Testen von Configuration Backend Models

Eine professionell entwickelte Magento-Extension will natürlich mit Unit Tests entwickelt werden und der modulare Aufbau sollte dies einfach machenIch nutze dazu die PHPUnit Erweiterung von EcomDev, die sich um das Ausführen der Tests, Instantiierung der Magento-Applikation, Test-Daten und mehr kümmert. In der Praxis tauchen aber häufig unerwartete Fallstricke auf, einen davon will ich hier kurz beschreiben:

Configuration Backend Models sind fürs Speichern und Laden von Werten in der Systemkonfiguration zuständig und können beispielsweise Anpassungen in beforeSave() und afterLoad() oder Validierungen vornehmen.

Mein erster Ansatz, diese Methoden zu testen, war das setzen über die Magento-Applikation:

// Hilfs-Methoden im Test Case:
	private function setConfig($value)
	{
		Mage::getConfig()->saveConfig(
			self::XML_PATH, $value)->cleanCache();
		// sicher stellen dass die neue Konfiguration gespeichert und wieder geladen ist:
		Mage::reset();
		Mage::init();
	}
	private function getConfig()
	{
		return Mage::getStoreConfig(
			self::XML_PATH);
	}

Dabei stellte sich heraus, dass mein Backend Model gar nicht benutzt wurde und natürlich ist der Fehler immer da wo man ihn zuletzt sucht: In Magentos Methoden zum setzen und abfragen von Konfigurationswerten wird direkt auf das Resource Model zugegriffen, das Backend Model kommt nur im User Interface zum Tragen.

Die Lösung ist also, das Backend Model direkt zu instantiieren und zu testen, dabei ist folgendes zu beachten:

Zunächst muss der bestehende Datensatz geladen werden, ansonsten gibt es beim Speichern Konflikte mit dem UNIQUE Index. Ich mache das über den Konfigurations-Pfad, bei mehreren Werten (z.B. Store-spezifische Konfiguration) ist die Abfrage mit load() leider nicht mehr eindeutig und müsste entsprechend zusammengebaut werden. So lange es für den Test nicht relevant ist, mit welchem konkreten Wert gearbeitet wird, kann es allerdings trotzdem so gemacht werden.

// Aufsetzen des Test Case:
	public function setUp()
	{
		parent::setUp();
		$this->model = Mage::getModel(self::MODEL)
			->load(self::XML_PATH, 'path');
	}

Die Hilf-Methoden von oben konnte ich dann refaktorisieren:

// Hilfsmethoden im Test Case:
	private function setConfig($value)
	{
		$this->model->setValue($value);
		$this->model->save();
	}
	private function getConfig()
	{
		return $this->model->getValue();
	}

Das ist so einfach dass nun eigentlich keine eigenen Methoden dafür mehr nötig wären, aber ich hatte sie nun schonmal angelegt. Weiterhin sind sie auf das Testen des Speicherns ausgerichtet (es ging mir konkret um beforeSave()), es sollte aber ein leichtes sein, bei Bedarf getConfig() um erneutes Laden zu erweitern.

Ein einfacher Test könnte nun so aussehen:

// Test
	/**
	 * @test
	 */
	public function testConvertKbOnSave()
	{
		$input = '1K';
		$expected = '1024';

		$this->setConfig($input);
		$this->assertEquals($expected, $this->getConfig(), 'configuration should be changed');
		$this->assertFalse($this->model->hasDataChanges(), 'configuration should be saved');
	}

Viel Erfolg!