Array vs ArrayAccess und PHPUnit
Array vs ArrayAccess und PHPUnit
Hallo! Ich bin Fabian Blechschmidt, Webentwickler aus Leidenschaft, wie soviele von uns und das hier wird meine Premiere… Meine ersten Erfahrungen mit PHP habe ich vor acht Jahren gemacht, als ich mit meinem Bruder zusammen in den Sommerferien in Tag- und Nachtarbeit einen Webshop zusammengehackt habe, ich weiß nicht, ob es davon zufällig noch irgendwo Code gibt, aber wenn ich ihn finde, zeige ich ihn euch nächstes Jahr zum ShameDay 🙂
Ansonsten habe ich mich in letzter Zeit eine ganze Menge mit PHPUnit und den schicken neuen Features von PHP 5.3 beschäftigt.
Ich wollte seit langem ein wenig Bloggen und euch an den Problemen teilhaben lassen, die mir unterwegs so begegnen. Jetzt habe ich ein Thema gefunden, was mich heute ganz schön beschäftigt hat und wo es sich meiner Meinung nach lohnt darüber zu schreiben.
Also, ab zum Thema:
DAS PROBLEM
(Object implements ArrayAccess) ist kein Array.
<?php
$this->assertArrayHasKey('key', Object);
?>
geht nicht.
Die Lösung gibt es unten.
DIE GESCHICHTE
Angefangen hat es damit, dass ich in einem großen Projekt gerade damit beschäftigt bin, zu refactorn (was für ein häßliches Wort). Wir bauen die Datenbank um und wenn wir schon dabei sind auch gleich die ganzen Models, die auf die Datenbank zugreifen.
Dabei haben wir ein Model, eine Listen-Klasse, die uns viele Objekte aus der Datenbank holt, sagen wir der Einfachheit halber Produkte.
Sieht in etwa so aus:
<?php
$list = new Product_List();
$options = array('isDownloadable' => false, 'nameLike' => 'schuh');
$res = $list->getList($options);
/*
Der Array der rauskommt:
$res = array(
10 => array('name' => 'brauner Lackschuh', 'downloadable' => false)
11 => array('name' => 'grüner Turnschuh', 'downloadable' => false)
);
*/
?>
Wir bekommen also einen Array zurück, die Array-Schlüssel sind die IDs der Produkte und der Wert ist ein Array mit den Werten des Produkts.
Damit ich mir sicher bin, bei der Umstellung nichts kaputt zu machen, schreibe ich vorher eine Menge PHPUnit-Tests.
Die sehen dann z.B. so aus:
<?php
Product_ListTest extends Project_TestCase {
public function testGetListIsDownloadable() {
$list = new Product_List();
$productList = $list->getList(array('getOnlyDownloadableProducts' => true));
foreach($productList as $product) {
$this->assertArrayHasKey('disallow_download', $product);
$this->assertArrayHasKey('id', $product);
$this->assertArrayHasKey('name', $product);
$this->assertArrayHasKey('name_short', $product);
$this->assertArrayHasKey('is_programm', $product);
$this->assertArrayHasKey('AllowUserChanges', $product);
}
}
}
?>
Wir gehen das Array durch und prüfen, ob alle Schlüssel die wir erwarten auch vorhanden sind. Ich gebe zu, ich pfusche hier ein wenig, weil man eigentlich prüfen sollte, ob auch drin steht, was man erwartet.
Jedenfalls läuft also obiger Unittest (und die anderen) durch und gibt ein erfreuliches:
PHPUnit 3.5.3 by Sebastian Bergmann. ...... Time: 3 seconds, Memory: 11.25Mb OK (6 tests, 9304 assertions)
So wie man das will.
Checkliste:
- Unit-Tests schreiben DONE
- Unit-Test zum Laufen bringen DONE
- Datenbank auf die neue Umstellen DONE
- Unit-Tests zum Laufen bringen FAILED
Auf der neuen Datenbankstruktur läuft erwartungsgemäß garnichts.
Also schreiben wir unsere Models um, für die Übergangszeit werden es quasi alles Adapter. Die Klassen bleiben alle gleich, die Methoden bleiben gleich, aber wir entkernen die Klassen und verweisen alle Anfragen an eine andere Klasse und reichen einfach die Parameter durch und die Resultate zurück.
/* Viele Stunden Arbeit später… */
Um einen Adapter zu haben, der die alten Strukturen gewährleistet, entscheide ich mich, der neuen Produkt-Klasse das Interface ArrayAccess mitzugeben, so dass man, wie früher, auch einfach via <?php $product[‚id‘] ?> auf die Eigenschaften zugreifen kann. Man muss ja nicht alles auf einmal umbauen.
/* weitere Stunden später… */
Die alte Klasse ist, dank der neuen Bibliothek und einiger neuen Implementierungen, wieder lauffähig – soweit mein Wunsch.
Also lassen wir den Unit-Test laufen
There was 1 error: 1) Product_ListTest::testGetListIsDownloadable Argument 2 passed to PHPUnit_Framework_Assert::assertArrayHasKey() must be an array, object given, called in [...]Product/ListTest.php on line 16 and defined [...]Product/ListTest.php:16
Was ist passiert?
Die Analyse hat gezeigt, das der Umbau in Ordnung war, leider prüft assertArrayHasKey, ob es sich um einen array handlet, merkt, das es ein Object ist und wirft einen Fehler.
Ich habe dann überlegt, was man da am Besten tut und kam auf folgende Lösungen:
- ich überschreibe den ArrayHasKey-Assert
- ich schreibe mir einen eigenen Assert
- ich ignoriere das und löse es Low-Level
Den ArrayHasKey-Assert zu überschreiben ist grundsätzlich eine schlechte Idee, so wie er prüft ist es gut und richtig, es gibt viele Funktionen, für die wichtig ist, ob es sich um einen Array oder um ein ArrayAcccess, Iterator oder Countable handelt, also Finger weg von Asserts, da weiß schon jemand was er tut.
Einen eigenen Assert zu schreiben, wöre eigentlich meine Wahl gewesen, aber ich will fertig werden und um ehrlich zu sein, habe ich nicht genug Ahnung von PHPUnit um mal schnell einen eigenen Assert zu schreiben, also lassen wir es sein und nehmen Tor 3:
Aus Gründen, die ich jetzt nicht groß ausführen will – das wird der nächste Beitrag, kann man zwischen einem ArrayAccess und einem Array nicht so richtig toll unterscheiden, lange Geschichte.
Für meine Zwecke reicht es auf <?php isset($product[$key]) || array_key_exists($key, $product) ?>
zu prüfen, also baue ich meinen PHP-Unit-Test um:
DIE LÖSUNG
<?php
Product_ListTest extends Project_TestCase {
public function testGetListIsDownloadable() {
$list = new Product_List();
$productList = $list->getList(array('getOnlyDownloadableProducts' => true));
foreach($productList as $product) {
$this->assertTrue(isset($product['disallow_download']) || array_key_exists('disallow_download', $product));
$this->assertEquals(0, $product['disallow_download']);
$this->assertTrue(isset($product['id']) || array_key_exists('id', $product));
$this->assertTrue(isset($product['name']) || array_key_exists('name', $product));
$this->assertTrue(isset($product['name_short']) || array_key_exists('name_short', $product));
$this->assertTrue(isset($product['is_programm']) || array_key_exists('is_programm', $product));
$this->assertTrue(isset($product['AllowUserChanges']) || array_key_exists('AllowUserChanges', $product));
}
}
}
?>
Ich hoffe ich habe damit irgendwem geholfen. Und wenn icht war es vielleicht wenigstens unterhaltsam.
Wieso prüfst du mit isset und mit array_key_exists? Wie kann der Wert gesetzt sein ohne, dass der Key existiert?
Ich habe mich zwar zuerst gefragt wo diese komische Stadt „Leidenschaft“ liegt, aber dann hab ich verstanden, dass du es ernst meinst 🙂
Hallo, hab den Artikel grad nur überflogen, aber ich hatte gerade etwas aehnliches. Habe es ganz unpragmatisch in etwas so gelöst:
Objekt implementiert die __toArray() Methode in der es das wrapped array zurückliefert.
Der Unit-test macht es dann einfach so:
$subject = $candidate->__toArray();
$this->assertArrayHasKey(‚aKey‘, $subject, ‚Array should have key „aKey“‚);
Klar testet das denn nicht direkt den Array-Access, aber das kann man ja separat testen.
Achso .. nochmal einmal Warnung vor „isset()“ … das liefert ja zum Beispiel auch gerne mal „false“ bei Array-Keys, wenn der Wert „null“ ist!
Hallo Ilja,
jep, dass mit dem $array[‚key‘] = null; isset($array[‚key‘]) === false ist mir auch schon negativ aufgestoßen.
Daher das isset($object[‚key‘]) für das Objekt und array_key_exists für den Array 🙂
Ist die Prüfung auf isset($product[‚id‘]) || array_key_exists(‚id‘, $product) in der Form nötig?
array_key_exists(‚id‘, $product) sollte doch reichen, denn wenn das false zurückgibt, ist isset definitiv auch false…
@David R und Thomas:
Ja die Prüfung ist nötig.
Ich habe hier z.B. ein CMS, welches Werte in einem Array speichert in der Form key => weitere Infos, wenn es keine Infos gibt, ist der value null, array_key_exists, gibt true zurück und führt damit das Modul aus, isset() führt das nicht aus, weil es false liefert.
@Fabian Blechschmidt: Ich bin mir nicht Sicher, ob ich dich richtig verstehe.. aber wenn du null als „true“ möchtest reicht array_key_exists, wenn null als „false“ gelten soll reicht isset.
bei einer OR-Verknüpfung kommt das gleiche bei raus als wenn du nur array_key_exists prüfen würdest…
@Thomas
Wir sind von einem Array auf ein ArrayAccess-Objekt umgestiegen, in dem ArrayAccess-Objekt funktioniert array_key_exists nicht, isset aber.
isset funktioniert aber dummerweise in einem Array anders als auf einem ArrayAccess-Objekt.
Wenn ich also wissen will, ob der Key existiert, muss beides herhalten.
Mich interessiert in dem Augenblick ja der Wert nicht, nur ob der Eintrag existiert.
jetzt hab ichs verstanden 🙂
„isset funktioniert aber dummerweise in einem Array anders als auf einem ArrayAccess-Objekt.“ – kommt darauf an, wie du die offsetExists-Methode implementierst.
In deinem Fall wird das wohl return array_key_exists($offset, $this->data); sein, dann liefert ein null-Wert true zurück.
Ein return isset($this->data[$offset]); verhält sich aber genau wie ein normales Array.
Echt blöd, dass array_key_exists (und die ganzen anderen array-Funktionen) nicht funktionieren 🙁
> Echt blöd, dass array_key_exists (und die ganzen anderen array-Funktionen)
> nicht funktionieren 🙁
Vorsicht! 🙂 Ich habe mich auch geärgert, aber bin inzwischen eher der Auffassung, das das gut so ist 🙂 Das Thema ist übrigens auf der Liste für den nächsten Beitrag, so ich einen Augenblick Ruhe finde… aber die Bachelorarbeit sitzt mir im Nacken, danach geht die Sonne wieder auf 😉
Gibt dazu auch eine Interessante Diskussion im PHP-Bug-Tracker:
http://bugs.php.net/bug.php?id=41727
http://bugs.php.net/bug.php?id=34849
> ArrayAccess objects don’t work in array_*() functions. You may want to turn this report into a feature request?
http://bugs.php.net/bug.php?id=34783
Also es gibt heiße Diskussionen zu dem Thema 😉
Wir arbeiten bei uns mit dem Iterator bzw. dem ArrayIterator-Objekt. Ich weiss nicht ob das 1:1 übertragbar ist, aber ich schreibe meine einfach Unittests so:
assertArrayHasKey(‚key‘, (array) Object);
?>
Ich denke das ist intern das gleiche wie das was Ilja vorschlägt:
$subject = $candidate->__toArray();
$this->assertArrayHasKey(‘aKey’, $subject, ‘Array should have key “aKey”‘);
@Stefan: (array) $object funktioniert leider nicht wie gewünscht, da es einfach nur die properties des Objektes als Array zurückliefert. Eine Magische __toArray()-Methode, die beim Umwandeln eines Objektes in einen Array aufgerufen wird (so wie es bei __toString gemacht wird) gibt es leider (noch) nicht…
Man könnte aber auch hingehen und bei dem offsetGet eine Exception werfen, sobald man auf einen Key zugreift, der gar nicht existiert. Dann kann man im Unittest die Asserts auf den richtigen Inhalt setzen und damit die Richtigkeit des Inhalts testen und wenn ein Key fehlt, fliegt eine Exception und der Unittest schlägt somit auch fehl 😉
@Norbert könnte man, aber dann ist das Verhalten nicht so wie vorher, obwohl der Fall nicht eintreten sollte… die Variante gefällt mir auch sehr gut 🙂