How to unit test calls to Google API

2019-04-07 23:39发布

问题:

I have a following method, which retrieves top visited pages from Google Analytics:

public function getData($limit = 10)
{
    $ids = '12345';
    $dateFrom = '2011-01-01';
    $dateTo = date('Y-m-d');

    // Google Analytics credentials
    $mail = 'my_mail';
    $pass = 'my_pass';

    $clientLogin = Zend_Gdata_ClientLogin::getHttpClient($mail, $pass, "analytics");
    $client = new Zend_Gdata($clientLogin);

    $reportURL = 'https://www.google.com/analytics/feeds/data?';

    $params = array(
        'ids' => 'ga:' . $ids,
        'dimensions' => 'ga:pagePath,ga:pageTitle',
        'metrics' => 'ga:visitors',
        'sort' => '-ga:visitors',
        'start-date' => $dateFrom,
        'end-date' => $dateTo,
        'max-results' => $limit
    );

    $query = http_build_query($params, '');
    $reportURL .= $query;

    $results = $client->getFeed($reportURL);

    $xml = $results->getXML();
    Zend_Feed::lookupNamespace('default');
    $feed = new Zend_Feed_Atom(null, $xml);

    $top = array();
    foreach ($feed as $entry) {
        $page['visitors'] = (int) $entry->metric->getDOM()->getAttribute('value');
        $page['url'] = $entry->dimension[0]->getDOM()->getAttribute('value');
        $page['title'] = $entry->dimension[1]->getDOM()->getAttribute('value');
        $top[] = $page;
    }

    return $top;
}

It needs some refactoring for sure, but the question is:

  • How would you write PHPUnit tests for this method?

回答1:

David Weinraub gave you the first half (how to set up your class to be mockable), so I'll address the second half (how to build the mock).

PHPUnit provides a great mocking facility with a simple API. Passing the user and password is too simple to test in my book, so I'd mock just the handling of the query and results. This requires mocks for Zend_Gdata and Zend_Gdata_App_Feed.

public function testGetData() {
    // expected input to and output from mocks
    $url = 'https://www.google.com/analytics/feeds/data?ids=ga:12345...';
    $xml = <<<XML
<feed>
    ...
</feed>
XML;
    // setup the mocks and method expectations
    $client = $this->getMock('Zend_Gdata', array('getFeed'));
    $feed = $this->getMock('Zend_Gdata_App_Feed', array('getXML'));
    $client->expects($this->once())
           ->method('getFeed')
           ->with($url)
           ->will($this->returnValue($feed));
    $feed->expects($this->once())
         ->method('getXML')
         ->will($this->returnValue($xml));
    // create the report (SUT) and call the method being tested
    $report = new MyReport();
    $report->setClient($client);
    $top = $report->getData();
    // check the final output; mocks are verified automatically
    $this->assertEquals(10, count($top));
    $this->assertEquals(array(
            'visitors' => 123, 
            'url' => 'http://...', 
            'title' => 'My Home Page'
        ), $top[0]);
}

The above will test that the URL was correct and return the XML feed expected from Google. It removes all dependence on the Zend_Gdata classes. If you don't use type hinting on setClient(), you can even use stdClass as the base for the two mocks since you will only be using mocked methods.



回答2:

As I understand it, typically you would want to inject the dependency (the Google client object) into the System Under Test (SUT, the class containing the getData() method).

I always see the experts use constructor injection - and I'm sure it's a better approach as it clearly identifies the dependencies right up front. But, to tell the truth, I can never seem to design my objects well enough to always make that work. So I end up doing with setter injection.

Something like this:

public function getClient()
{
    if (null === $this->_client){
        // $mail and $pass are stored somewhere, right?
        $clientLogin = Zend_Gdata_ClientLogin::getHttpClient($mail, $pass, "analytics");
        $this->_client = new Zend_Gdata($clientLogin);
    }
    return $this->_client;
}

public function setClient($client)
{
    $this->_client = $client;
    return $this;
}

Then in the unit test, you create a $client object as a mock of your live $client, setting up the expectations, and then inject it into your SUT using the setClient($client) method described above.

See what I mean?



回答3:

My first inclination is to tell you that this one function getData is one of the most nasty and ugliest piece of code. You are asking how to unit test this. Well guess what my recommendation is going to be? Refactor.

In order to refactor this code, you will need a coverage test.

The reasons for refactoring are many:

  1. Dependency on third-party framework.
  2. Dependency on external service.
  3. getData has too many responsibilites.

    a. Login in to external service using third-party framework.

    b. Create query for external service.

    c. Parse query response from external service.

How have you isolated your code from changes to either third-party framework and from external service?

You really should take a look at Michael Feather's Book. Working Effectively with Legacy Code

[EDIT]

My point to you (spoiler coming), is that with this code you can never get a true unit test. It is because of the dependency on external service. The unit test has no control over the service or the data it returns. A unit test should be able to execute such that every time it executes it's outcome is consistent. With an external service this may not be the case. YOU HAVE NO CONTROL OVER WHAT THE EXTERNAL SERVICE RETURNS.

What do you do if the service is down? Unit test FAIL.

What if the results are returned changes? Unit test FAIL.

Unit tests results must remain consistent from execution to execution. Otherwise it is not a unit test.