Einige sehr grundlegende, eigentlich selbstverständliche, Best Practices für Magento-Entwickler:
http://prattski.com/magento-dev-best-practices/
Ich empfehle, jedem Magento-Entwickler sie einmal durchzulesen. Hand aufs Herz: Befolgst Du sie alle?
Dipl. Inform. Fabian Schmengler | Magento Certified Developer | Magento Certified Solution Specialist
Einige sehr grundlegende, eigentlich selbstverständliche, Best Practices für Magento-Entwickler:
http://prattski.com/magento-dev-best-practices/
Ich empfehle, jedem Magento-Entwickler sie einmal durchzulesen. Hand aufs Herz: Befolgst Du sie alle?
Gelegentlich funktioniert die Weiterleitung beim One Page Checkout nicht mehr, nachdem irgendein neues Modul Debug-Ausgaben macht oder Fehler wirft. Exceptions beim Versand der Bestätigungs-Mail sind auch eine häufige Ursache.
Das ist ein ziemlich gravierender Fehler, der aber leicht unbemerkt bleibt. Nachdem ich das nun ein paar Mal hatte, war klar: Der Checkout muss beim Regressionstest automatisch getestet werden, so dass ich über so einen Fehler rechtzeitig informiert werde. Alistair Steads hat glücklicherweise beispielhaft einen Checkout Integration Test online gestellt, seine Bibliothek Mage-Test benutze ich allerdings nicht mehr, habe den Test also für EcomDev PHPUnit 0.2 umgeschrieben. Am Code der Test-Methode ändert sich zunächst nicht viel, nur ein paar Methoden heißen anders. Das Setup ist allerdings mal wieder etwas kniffelig, daher will ich es hier mal etwas näher beleuchten. Links zum fertigen Test Case folgen unten.
Zunächst mal wird mindestens ein Produkt für den Warenkorb benötigt, die Unit Tests laufen ja auf ihrer eigenen Datenbank, die für jeden Test mittels Fixtures entsprechend vorbereitet werden muss (mehr dazu im EcomDev PHPUnit Manual). Hier die Minimal-Daten für unsere Zwecke:
eav: catalog_product: - entity_id: 1 stock: qty: 100 is_in_stock: 1 website_ids: - default sku: test name: Test status: 1 # enabled type_id: simple price: 1.00
Um sicherzustellen, dass die Bestellung akzeptiert wird, sollte das Empfängerland explizit erlaubt sein und die Funktion “Terms & Conditions” deaktiviert werden:
config: default/general/country/allow: DE,GB stores/default/checkout/options/enable_agreements: 0
Außerdem muss die Zahlungsmethode konfiguriert sein. Ich habe mich dazu entschieden, für den Test “Scheck / Zahlungsanweisung” statt Paypal zu verwenden, die Standard-Methode, die keine Konfiguration benötigt. Wenn explizit verschiedene Zahlungsmethoden getestet werden sollen, ist ein aufwändigeres Setup nötig, das macht meiner Meinung nach in der isolierten Testumgebung aber wenig Sinn.
Ganz wichtig ist, dass nachdem der Warenkorb gefüllt wurde, der Session-Wert cart_was_updated auf false gesetzt wird, ansonsten bricht der Test mit einer wenig aussagekräftigen “Headers already sent”-Exception ab (mehr dazu unten).
protected function _fixCheckout() { Mage::getSingleton('checkout/session')->setCartWasUpdated(false); }
Eine weitere Überraschung gab es, nachdem der Test das erste Mal erfolgreich durchlief und beim nächsten Mal ohne ersichtlichen Grund einen Fehler in der JSON-Ausgabe meldete. Gut, wenn man Exception-Logging aktiviert hat: Grund war eine PDOException aufgrund doppeltem Schlüssel in der Order-Tabelle. Das interessante: Das EcomDev Test-Framework räumt die Datenbank eigentlich nach jedem Testlauf ab (wenn nicht gerade ein Fatal Error der Ausführung der tearDown-Methoden zuvorkommt) aber für die beim Checkout generierte Bestellung (und nur die) funktionierte das nicht. Als Ursache vermute ich die zusätzliche Kontrolle im Order-Model, die verhindern soll, dass Bestellungen von irgendeinem Modul außerhalb des Admin-Bereichs gelöscht werden können. Jedenfalls hat das setzen des isSecureArea-Flags geholfen, generierte Bestellungen wieder zu löschen:
/** * Deletes any created order * * @return void */ protected function _deleteOrders() { Mage::register('isSecureArea', true); /* @var $orders Mage_Sales_Model_Mysql4_Order_Collection */ $orders = Mage::getModel('sales/order')->getCollection()->load(); $orders->walk('delete'); }
stores/default/dev/log/active: 1 stores/default/dev/log/file: tests-system.log stores/default/dev/log/exception_file: tests-exception.log
Wie versprochen, stehen Fixture & Test Case als github:gist online zur Verfügung.
Aktiviere den Zend SQL Profiler mit dem folgenden Knoten in local.xml
:
<resources> <default_setup> <connection> <profiler>1</profiler>
Dann kannst Du auf den Profiler an beliebiger Stelle im Code zugreifen und einiges an Informationen über die ausgeführten Queries erhalten:
$profiler = Mage::getSingleton('core/resource') ->getConnection('core_write')->getProfiler();
Um alle Queries auszugeben genügt z.B.:
print_r($profiler->getQueryProfiles());
Magento bringt mit TinyMCE von Haus aus einen Rich Text Editor mit, der im Backend für Attribute, CMS-Seiten u.a. verwendet wird. Die Integration erfolgt über diverse Adapterklassen und Skripte, die quer über den Magento-Core verteilt sind, darunter das Formular-Element Varien_Data_Form_Element_Editor, ein paar Models im CMS-Modul und diverse Blocks und Helpers im Adminhtml-Modul sowie eigene JavaScripts in js/mage/adminhtml/wysiwyg.
Ich habe nun für meine letzte Extension ein eigenes TinyMCE Plugin entwickelt und stand vor dem Problem, wie ich es aktivieren könnte. Ein einfaches einbinden des Plugin-Skripts schlug fehl, da der TinyMCE Editor vom Magento-Adapter dynamisch nachgeladen wird. Leider habe ich dafür auch nach langem Suchen keine brauchbare Dokumentation gefunden und da Replacements und Hacks von Core-Dateien nicht in Frage kommen blieb mir letztlich nichts übrig als die oben genannten Klassen und Skripte zu analysieren. Als Resultat gibt es hier nun eine Anleitung zum einbinden von eigenen Plugins:
Die Konfiguration für den Editor wird zentral vom ‘cms/wysiwyg_config’ Singleton geladen (auch außerhalb des CMS-Moduls!). Dort ist leider sehr viel hart kodiert und es gibt z.B. keine Konfigurationsquelle für Plugins. Dafür wird dort allerdings ein Event getriggert so dass man die Konfiguration mittels Observer erweitern kann. Das Event dazu heißt cms_wysiwyg_config_prepare und hat als Parameter ein Varien_Object namens config.
Ein Observer dafür könnte so aussehen:
class SSE_Example_Model_Observer { public function onCmsWysiwygConfigPrepare(Varien_Event_Observer $observer) { $config = $observer->getEvent()->getConfig(); $this->addPlugin($config, $this->getContentBlockPluginConfig()); } private function addPlugin(Varien_Object $config, $plugin) { $config->setData('plugins', array_merge((array) $config['plugins'], array($plugin))); } private function getContentBlockPluginConfig() { return array( 'options' => array( ), 'name' => 'example', 'src' => Mage::getBaseUrl(Mage_Core_Model_Store::URL_TYPE_JS) . 'sse/example/editor_plugin.js', ); }
In der Methode getContentBlockPluginConfig() wird hier die Konfiguration für ein neues Plugin angelegt:
Üblicherweise muss man neue Steuerelemente über die theme_advanced_buttons{n} Parameter beim Initialisieren von TinyMCE im Menü einfügen. Da diese Initialisierung aber wie gesagt komplett von Magento übernommen wird, gibt es hier einfach die Konvention dass das Element genauso heißt wie das Plugin (Magento fügt den Namen an vorderster Position in der ersten Reihe ein):
// ... tinymce.create('tinymce.plugins.Example', { init : function(editor, url) { // ... editor.addButton('example', { // ... }); }, // ... }
Dadurch dass die Konfiguration zentral über ein Singleton geladen wird, ist sie leider für jede Editor-Instanz die selbe. In meinem Fall brauchte ich das Plugin jedoch nur für die Produktbeschreibung und wollte es entsprechend nicht in anderen Formularen aktiviert haben. Das ließ sich letztendlich am besten clientseitig bewerkstelligen, indem bei der Plugin-Initialisierung die ID des Editors abgefragt wird (Die ID lässt sich am besten über den DOM-Inspektor von Firebug oder einem ähnlichen Tool herausfinden):
// ... tinymce.create('tinymce.plugins.Example', { init : function(editor, url) { if (editor.id == "description_editor") { this._init(ed, url); } }, _init : function(editor, url) { // real initialization here }, // ... }
Wie so oft bei Magento: Eigentlich ganz einfach, wenn man nur weiß wie es geht. Ich hoffe, dem ein oder anderen hiermit das stundenlange Wühlen im Core zu ersparen was ich dafür auf mich nehmen musste 🙂
Notes:
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.
Eine professionell entwickelte Magento-Extension will natürlich mit Unit Tests entwickelt werden und der modulare Aufbau sollte dies einfach machen
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!
Worauf man bei Rewrites von Shipping Carriers achten muss:
Frage und Antwort on StackOverflow
Wer in einem Setup-Skript mittels addAttribute()
der Customer-Enitität neue Attribute hinzufügen will, erlebt ab Magento 1.5 eine Überraschung. Das Setup läuft zwar fehlerfrei und die DB-Einträge in customer_eav_attribute
sind anschließend vorhanden aber es findet sich im Backend kein neues Formular-Element.
Was mit anderen Entitäten wie z.B. Kategorien problemlos funktioniert und bis Magento 1.4 auch mit Kunden, benötigt hier nun eine zusätzlichen Setup-Schritt mit dem das Attribut explizit dem Admin-Formular hinzugefügt wird.
Dazu existiert eine neue Tabelle customer_form_attribute
, die für jedes Attribut festlegt, in welchen Formularen es verwendet wird:
mysql> select * from customer_form_attribute;
+----------------------------+--------------+
| form_code | attribute_id |
+----------------------------+--------------+
| adminhtml_customer | 1 |
| adminhtml_customer | 3 |
| adminhtml_customer | 4 |
| checkout_register | 4 |
| customer_account_create | 4 |
| customer_account_edit | 4 |
| adminhtml_customer | 5 |
| checkout_register | 5 |
| customer_account_create | 5 |
| customer_account_edit | 5 |
| adminhtml_customer | 6 |
| checkout_register | 6 |
| customer_account_create | 6 |
| customer_account_edit | 6 |
| adminhtml_customer | 7 |
| checkout_register | 7 |
| customer_account_create | 7 |
| customer_account_edit | 7 |
[...]
+----------------------------+--------------+
88 rows in set (0.00 sec)
Aber dies nur zum Verständnis, natürlich muss man nicht selber an der Datenbank Hand anlegen, es genügen folgende Zeilen nach dem addAttribute()
-Aufruf im Setup-Skript:
Mage::getSingleton('eav/config') ->getAttribute('customer', $attributeCode) ->setData('used_in_forms', array('adminhtml_customer')) ->save();
wobei $attributeCode
der eindeutige Code des neuen Attributs ist und 'adminhtml_customer'
der Code für das Kundenverwaltungsformular im Backend. Um die Setup-Skripte übersichtlicher zu halten empfiehlt sich eine Erweiterung der Entity Setup-Klasse etwa wie folgt:
class MyNamespace_ExtendableCustomer_Model_Entity_Setup extends Mage_Customer_Model_Entity_Setup { public function addAttributeToForm($attributeCode, $formCode) { Mage::getSingleton('eav/config') ->getAttribute('customer', $attributeCode) ->setData('used_in_forms', (array) $formCode) ->save(); } }
Nutzt man nun diese Klasse für seine Setup-Skripte, die Kundenattribute hinzufügen, ist es ganz einfach:
$this->addAttribute('customer', 'neues_attribut', array( /* ... */); $this->addAttributeToForm('neues_attribut', 'adminhtml_customer');
Analog kann das Attribut auch zu anderen Formularen hinzugefügt werden (Codes siehe oben).
Danke ans Magento-Forum für den entscheidenden Hinweis!
Achtung, beim Speichern wird zusätzlich das Feld eav_entity_attribute.sort_order
gesetzt, unabhängig davon ob dieses schon vorher spezifiziert wurde!
Siehe dazu mein Kommentar auf StackOverflow.
Weiterhin wirkt sich sort_order
nur aus wenn beim Anlegen des Attributs user_defined => 0
gesetzt wird, da vor sort_order
nach user_defined
sortiert wird!