📜 ⬆️ ⬇️

PHP sample development case using TDD

This post provides a tutorial example of developing a PHP class that makes a request to the Twitter API in order to fetch user statuses by its nickname. In addition, the Twitter class caches the received data using another PHP class that performs simple data caching in files.

The purpose of the post is to consolidate their own knowledge, obtained as a result of reading some books, articles, as well as the opportunity to receive comments from experienced TDD practitioners, indicating gross errors in the development process or in tests.


Setting the task and requirements


So, it is required to develop through testing a class in the PHP language, which is able to make requests to the Twitter API and return the data received in response. It is also necessary to provide that the Twitter object may use (or may not use) the caching object. The caching object must store data in a predefined directory with a predetermined lifetime.
Since the development of training, we agree that the data on the status will be returned raw, in JSON format.
Testing should be done using the PHPUnit framework.
')
Let us proceed to the description of the requirements for each class, on the basis of which tests will be compiled.

FileCache caching class:
  1. The class must implement the CacheInterface interface.
  2. It should be possible to set the directory where cached data will be saved.
  3. It should be possible to set the cache lifetime.
  4. If you try to select non-existent data, it must return false.

I will give some explanations. Since there can be a lot of caching opportunities, you need to pre-declare an interface for each such class, in this case CacheInterface. The remaining requirements are understandable, in my opinion.

Twitter class:
  1. The object must call the HTTP client method with the correct URL.
  2. The object must cache its data if there is such a possibility (if the caching object is specified).

The HTTP client should be a third-party object, since it should not be tied to the Twitter class, so it should simply return the data received at the passed URL. Since the HTTP client is dependent on external factors, and I still do not know how to test such objects, this class will be developed primitive without the use of testing.

We start to develop


Let's start development with the FileCache class, write the first test:

public function testFileCacheClassShouldImplementCacheInterface() { $fileCache = new FileCache(); $this->assertInstanceOf('CacheInterface', $fileCache); } 

The test is quite simple, and it starts successfully after the interface description:

 interface CacheInterface { /** * @abstract * @param string $id * @param mixed $data * @return bool */ public function save($id, $data); /** * @abstract * @param string $id * @return mixed */ public function load($id); } 

... and after the initial description of the FileCache class:

 class FileCache implements CacheInterface { public function save($id, $data){} public function load($id) {} } 

To implement the further two tests, it is necessary to program the fixture preparation methods, namely the creation and clearing of the directory with the cache files:

 class FileCacheTest extends PHPUnit_Framework_TestCase { protected $cacheDir = './cache_data'; protected function setUp() { //Create cache dir if (file_exists($this->cacheDir)) { $this->_removeCacheDir(); } mkdir($this->cacheDir); } public function tearDown() { //remove cache dir $this->_removeCacheDir(); } protected function _removeCacheDir() { $dir = opendir($this->cacheDir); if ($dir) { while ($file = readdir($dir)) { if ($file != '.' && $file != '..') { unlink($this->cacheDir . '/' . $file); } } } closedir($dir); rmdir($this->cacheDir); } } 

And tests:

 public function testSettingCacheDir() { $beforeFilesCount = count(scandir($this->cacheDir)); $fileCache = new FileCache($this->cacheDir); $fileCache->save('data_name', 'some data'); $afterFilesCount = count(scandir($this->cacheDir)); $this->assertTrue($afterFilesCount > $beforeFilesCount); } 

This test checks whether the cache file actually appeared in the directory that was specified when the FileCache object was created.

To make the test work successfully, I made minimal changes to the FileCache class:

 class FileCache implements CacheInterface { /** * @var string */ protected $cacheDir; /** * @param string $cacheDir */ public function __construct($cacheDir = '.') { $this->cacheDir = $cacheDir; } /** * @param string $id * @param mixed $data * @return bool */ public function save($id, $data) { $filename = $this->cacheDir . '/' . $id . '.dat'; $f = fopen($filename, 'w'); fwrite($f, serialize($data)); fclose($f); return true; } } 

The following test that implements a cache lifetime check:

 public function testSettingCacheLifetime() { $lifetime = 2; $cacheData = 'data'; $cacheId = 'expires'; $fileCache = new FileCache($this->cacheDir, $lifetime); $fileCache->save($cacheId, $cacheData); $this->assertEquals($cacheData, $fileCache->load($cacheId)); sleep(3); $this->assertFalse($fileCache->load($cacheId)); } 

The test checks the availability of cache data before and after its “swelling”. The implementation of this test in my opinion is unacceptable when developing a larger project, since the sleep (3) operator delays the test for 3 seconds. The most suitable option is to manually change the file access time.

Add a class cache lifetime assignment to the class constructor:

 /** * @param string $cacheDir * @param int $lifetime */ public function __construct($cacheDir = '.', $lifetime = 3600) { $this->cacheDir = $cacheDir; $this->lifetime = $lifetime; } 

And add the load method:

 /** * @param string $id * @return mixed */ public function load($id) { $filename = $this->cacheDir . '/' . $id . '.dat'; if (time() - fileatime($filename) > $this->lifetime) { return false; } return unserialize(file_get_contents($filename)); } 

At this stage, it is already possible to refactor code in order to remove duplication of code in the FileCache class, namely, the creation of the file name in the load and save methods. To do this, add the private method _createFilename. This piece of code has already been tested, so you can not be afraid that the closed method will be untested (source: blog.byndyu.ru , I don’t remember the exact post, but all articles are equally useful and interesting).

 protected function _createFilename($id) { return $this->cacheDir . '/' . $id . '.dat'; } 

Last test:

 public function testLoadShouldReturnFalseOnNonexistId() { $fileCache = new FileCache($this->cacheDir); $fileCache->save('id', 'some data'); $this->assertFalse($fileCache->load('non_exist')); } 

In order for the test to work, you just need to add a piece of code to the load method:

 public function load($id) { $filename = $this->_createFilename($id); if (!file_exists($filename)) { return false; } if (time() - fileatime($filename) > $this->lifetime) { return false; } return unserialize(file_get_contents($filename)); } 

So, all the tests for the FileCache class work, you can proceed to implement the Twitter class.

We develop further



We start by writing a test for the first requirement:

 public function testTwitterShouldCallHttpClientWithCorrectUrl() { $httpClient = $this->getMock('HttpClientInterface'); $nickname = 'test_nick'; $twitter = new Twitter($httpClient); $httpClient ->expects($this->once()) ->method('get') ->with($this->equalTo('http://api.twitter.com/1/statuses/user_timeline.json?screen_name=' . $nickname)); $twitter->getStatuses($nickname); } 

This test will verify that the URL that will be used to fetch data from the Twitter API is passed correctly to the Http client object. Since the tests should be independent, I use the mock object to simulate the Http client. I describe which method should be called from the mock object, how many times and with what parameters. Read more about this in the documentation for PHPUnit.
I will immediately give you another test that tests the second requirement for the Twitter class:

 public function testTwitterShouldLoadDataFromCacheIfIsPossible() { $cache = $this->getMock('CacheInterface'); $httpClient = $this->getMock('HttpClientInterface'); $nickname = 'test_nick'; $twitter = new Twitter($httpClient); $url = 'http://api.twitter.com/1/statuses/user_timeline.json?screen_name=' . $nickname; $urlMd5 = md5($url); $resultCached = array('status1', 'status2', 'status3'); $resultNotCached = array('save_to_cache'); $twitter->setCache($cache); $cache->expects($this->at(0))->method('load')->with($this->equalTo($urlMd5))->will($this->returnValue($resultCached)); $cache->expects($this->at(1))->method('load')->with($this->equalTo($urlMd5))->will($this->returnValue(false)); $httpClient->expects($this->once())->method('get')->with($this->equalTo($url))->will($this->returnValue($resultNotCached)); $cache->expects($this->once())->method('save')->with($this->equalTo($urlMd5), $this->equalTo($resultNotCached)); $this->assertEquals($resultCached, $twitter->getStatuses($nickname)); $this->assertEquals($resultNotCached, $twitter->getStatuses($nickname)); } 

This test is quite voluminous. It also uses mock objects. In addition to the Http client's mock object, the cash class mock object is also used, despite the fact that this class has already been developed (remember about the independence of the tests). The test checks whether HTTP is accessed if the data is already in the cache. In addition, the validity of the returned data is verified.
The source code for the Twitter class that performs both tests is as follows:

 class Twitter { /** * @var HttpClientInterface */ protected $httpClient; /** * @var string */ protected $methodUrl = 'http://api.twitter.com/1/statuses/user_timeline.json'; /** * @var CacheInterface */ protected $cache = null; /** * @param HttpClientInterface $httpClient */ public function __construct(HttpClientInterface $httpClient) { $this->httpClient = $httpClient; } /** * @param CacheInterface $cache * @return Twitter */ public function setCache(CacheInterface $cache) { $this->cache = $cache; return $this; } /** * @param string $nickname * @return mixed */ public function getStatuses($nickname) { $url = $this->methodUrl . '?screen_name=' . $nickname; $cache = $this->cache; $cacheId = md5($url); $data = false; if ($cache !== null) { $data = $cache->load($cacheId); } if ($data === false) { $data = $this->httpClient->get($url); if ($cache !== null) { $cache->save($cacheId, $data); } } return $data; } } 

Done!


Both classes are designed through testing, they work properly. To test the real work, I wrote a simple HTTP client that returns the result of the file_get_contents function, and also wrote a simple php script that displays the results of the work, but this is beyond the scope of the article.
The project is also posted on GitHub: github.com/xstupidkidzx/tddttl
In your comments, I would like to see comments on this article. Problem points that I encountered during deliberation, and which I would like to read in the comments:
  1. How widely should the functionality be covered by a separate test? Should a test test only one method? Or he can test a variety of methods or a separate piece of one method?
  2. Is it necessary to thoroughly test every aspect (for example, the correctness of the parameters passed to the method or constructor)? Let's say whether it was worth including testing - is the FileCache class a CacheInterface interface?

Thanks for attention!

Source: https://habr.com/ru/post/130086/


All Articles