Usando oggetti fittizi in PHP all'interno di funzioni che istanziano i propri oggetti

Ho esaminato come aggiungere la copertura dei test unitari a un codebase ampio e esistente scritto in PHP. Molte funzioni in entrambe le classi statiche e istanziabili effettuano una chiamata a una libreria o creano un'istanza di un object per get connessioni a memcache e al database. Di solito assomigliano a qualcosa del genere:

public function getSomeData() { $key = "SomeMemcacheKey"; $cache = get_memcache(); $results = $cache->get($key); if (!$results) { $database = new DatabaseObject(); $sql = "SELECT * from someDatabase.someTable"; $results = $database->query($sql); $cache->set($key, $results); } return $results; } 

I miei colleghi e io stiamo attualmente cercando di implementare la copertura tramite PHPUnit per alcune delle nuove classi che stiamo scrivendo. Ho tentato di trovare un modo per creare unit test in modo isolato per le funzioni nella nostra base di codice esistente che assomiglia allo pseudo-codice sopra, ma che non hanno avuto successo.

Gli esempi che ho visto nella documentazione di PHPUnit si basano tutti sull'avere qualche metodo nella class con il quale un object mock può essere collegato ad esso, come ad esempio: $objectBeingTested->attach($mockObject); Ho guardato SimpleUnit e ho visto la stessa cosa lì, gli oggetti finti sono stati passati alla class tramite il suo constructor. Questo non lascia molto spazio a funzioni che istanziano i propri oggetti di database.

C'è un modo per prendere in giro questo tipo di chiamate? C'è un altro framework di test unitario che possiamo usare? O dovremo cambiare i pattern che stiamo usando in futuro per facilitare il test unitario?

Quello che mi piacerebbe fare è essere in grado di scambiare un'intera class con una class di simulazione durante l'esecuzione dei test. Ad esempio, la class DatabaseObject potrebbe essere sostituita con una class mock, e each volta che viene istanziata durante un test, sarebbe in realtà un'istanza della versione fittizia.

Si è parlato nel mio team di refactoring dei nostri methods di accesso al database e memcache in un nuovo codice, magari usando singleton. Suppongo che potrebbe essere d'aiuto se wheressimo scrivere il singleton in modo tale che la sua stessa istanza possa essere sostituita con un object finto …

Questa è la mia prima incursione nei test unitari. Se sto sbagliando, per favore dillo. 🙂

Grazie.

In un mondo perfetto, avresti il ​​tempo di rifattorizzare tutto il tuo codice legacy per usare l'injection dependency o qualcosa di simile. Ma nel mondo reale, spesso devi gestire la mano che ti è stata data.

Sebastian Bergmann, l'autore di PHPUnit, ha scritto un'estensione per aiutanti di prova che consente di ignorare il nuovo operatore con funzioni di callback e rinomina. Questi ti permetteranno di eseguire il patching del codice durante il test fino a che non potrai renderlo più testabile. Certo, più test scrivi usando questo, più lavori avrai annullato.

Nota: l'estensione Test-Helper è sostituita da https://github.com/krakjoe/uopz

Solo per aggiungere a @Ezku la risposta (+1, tutto quello che avrei detto anche io) al codice finale potrebbe assomigliare a questo (usando l' iniezione di dipendenza )

 public function __construct(Memcached $mem, DatabaseObject $db) { $this->mem = $mem; $this->db = $db; } public function getSomeData() { $key = "SomeMemcacheKey"; $cache = $this->mem; $results = $cache->get($key); if (!$results) { $database = $this->db; $sql = "SELECT * from someDatabase.someTable"; $results = $database->query($sql); $cache->set($key, $results); } return $results; } 

Con questo è veramente facile creare gli oggetti mock e passarli nel codice.

Ci sono diversi motivi per cui potresti voler fare questo (a parte la creazione di codice verificabile). Per una volta rende il tuo codice molto più aperto al cambiamento (vuoi db? Passare in un object db diverso invece di cambiare il codice nel tuo DatabaseObject.

Questo post sul blog ti dice sul perché i methods statici sono cattivi, ma usare l'operatore "nuovo" nel tuo codice è praticamente la stessa cosa che dire $x = StaticStuff::getObject(); quindi si applica anche qui.

Un altro riferimento può essere: Perché i singleton sono dannosi per il codice verificabile perché tocca gli stessi punti.

Se hai già scritto un po 'di codice, ci sono alcuni modi per far funzionare queste idee senza cambiare tutto in una volta.

Iniezione di dipendenza facoltativa come questa:

 public function __construct(Memcached $mem = null, DatabaseObject $db = null) { if($mem === null) { $mem = new DefaultCacheStuff(); } if($db === null) { $db = new DefaultDbStuff(); } $this->mem = $mem; $this->db = $db; } public function getSomeData() { $key = "SomeMemcacheKey"; $cache = $this->mem; $results = $cache->get($key); if (!$results) { $database = $this->db; $sql = "SELECT * from someDatabase.someTable"; $results = $database->query($sql); $cache->set($key, $results); } return $results; } 

o usando "setter injection":

 public function __construct(Memcached $mem = null, DatabaseObject $db = null) { $this->mem = new DefaultCacheStuff(); $this->db = new DefaultDbStuff(); } public function setDatabaseObject(DatabaseObject $db) { $this->db = $db; } public function setDatabaseObject(Memcached $mem) { $this->mem = $mem; } public function getSomeData() { $key = "SomeMemcacheKey"; $cache = $this->mem; $results = $cache->get($key); if (!$results) { $database = $this->db; $sql = "SELECT * from someDatabase.someTable"; $results = $database->query($sql); $cache->set($key, $results); } return $results; } 

Inoltre ci sono cose chiamate dependency injection containers che ti permettono di mettere via tutta la tua obiezione e di estrarre tutto da quel contenitore, ma dal momento che rende il test un po 'più difficile (imho) e ti aiuta solo se fatto davvero bene non lo farei suggerire di iniziare con uno, ma semplicemente usando la normale "iniezione di dipendenza" per creare un codice verificabile.

Questo non lascia molto spazio a funzioni che istanziano i propri oggetti di database.

Proprio così. Stai descrivendo uno stile di programmazione che è considerato da evitare proprio perché conduce a un codice non verificabile. Se il tuo codice dipende esplicitamente da alcune esternalità e non è in alcun modo astratto su di esse, sarai solo in grado di testare quel codice con quelle esternalità intatte. Come dici tu, non puoi prendere in giro cose che le funzioni creano per se stesse.

Per rendere testabile il tuo codice, è preferibile applicare l'iniezione delle dependencies: passa le dependencies che desideri siano mockable nel context dell'unità dall'esterno. Solitamente questo è visto come il risultato di un design di class migliore in primo luogo.

Detto questo, ci sono alcune cose che puoi fare per abilitare la mockability senza un'iniezione esplicita: usando le strutture degli oggetti mock di PHPUnit, puoi sovrascrivere i methods anche nell'unità sotto test. Considera un refactoring come questo.

 public function getSomeData() { $key = "SomeMemcacheKey"; $cache = $this->getMemcache(); $results = $cache->get($key); if (!$results) { $database = $this->getDatabaseObject(); $sql = "SELECT * from someDatabase.someTable"; $results = $database->query($sql); $cache->set($key, $results); } return $results; } public function getMemcache() { return get_memcache(); } public function getDatabaseObject() { return new DatabaseObject(); } 

Ora, se stai testando getSomeData (), puoi prendere in giro getMemcache () e getDatabaseObject (). La prossima fase di refactoring sarebbe quella di iniettare gli oggetti memcache e database nella class in modo che non avrebbe alcuna dipendenza esplicita su get_memcache () o la class DatabaseObject. Ciò eliminerebbe la necessità di methods di simulazione nell'unità sotto test stesso.

Suggerirei un iniettore di dependencies molto semplice. Possono essere molto molto facili da usare per nuove funzioni all'interno del codice legacy. Inoltre puoi facilmente rievocare il codice che hai pubblicato.

Suggerisco uno semplice come quello che ho sviluppato di recente per un'occasione simile: https://packagist.org/packages/tflori/dependency-injector

In alcuni file bootstrap o file di configuration scrivi qualcosa come questo:

 <?php DI::set('database', function() { return new DatabaseObject(); }); DI::set('memcache', function() { return get_memcache(); }); 

E quindi la tua function può assomigliare a questa:

 <?php function getSomeData() { $key = "SomeMemcacheKey"; $cache = DI::get('memcache'); $results = $cache->get($key); if (!$results) { $database = DI::get('database'); $sql = "SELECT * from someDatabase.someTable"; $results = $database->query($sql); $cache->set($key, $results); } return $results; } 

Per testare il codice puoi scrivere un testClass come questo:

 <?php use PHPUnit\Framework\TestCase; class GetSomeDataTest extends TestCase { public function tearDown() { Mockery::close(); parent::tearDown(); } public function testReturnsCached() { $mock = Mockery::mock('memcache_class'); $mock->shouldReceive('get')->once()->with('SomeMemcacheKey')->andReturn('anyResult'); DI::set('memcache', $mock); $result = getSomeData(); $this->assertSame('anyResult', $result); } public function testQueriesDatabase() { $memcache = Mockery::mock('memcache_class'); $memcache->shouldReceive('get')->andReturn(null); $memcache->shouldIgnoreMissing(); DI::set('memcache', $memcache); $database = Mockery::mock(DatabaseObject::class); $database->shouldReceive('query')->once()->andReturn('fooBar'); DI::set('database', $database); $result = getSomeData(); $this->assertSame('fooBar', $result); } }