How is testing the registry pattern or singleton hard in PHP?

前端 未结 3 1631
小蘑菇
小蘑菇 2020-11-30 02:56

Why is testing singletons or registry pattern hard in a language like PHP which is request driven?

You can write and run tests aside from the actual program executio

相关标签:
3条回答
  • 2020-11-30 03:05

    While it's true that "you can write and run tests aside of the actual program execution so that you are free to affect the global state of the program and run some tear downs and initialization per each test function to get it to the same state for each test.", it is tedious to do so. You want to test the TestSubject in isolation and not spend time recreating a working environment.

    Example

    class MyTestSubject
    {
        protected $registry;
    
        public function __construct()
        {
            $this->registry = Registry::getInstance();
        }
        public function foo($id)
        {
            return $this->doSomethingWithResults(
                $registry->get('MyActiveRecord')->findById($id)
            );
        }
    }
    

    To get this working you have to have the concrete Registry. It's hardcoded, and it's a Singleton. The latter means to prevent any side-effects from a previous test. It has to be reset for each test you will run on MyTestSubject. You could add a Registry::reset() method and call that in setup(), but adding a method just for being able to test seems ugly. Let's assume you need this method anyway, so you end up with

    public function setup()
    {
        Registry::reset();
        $this->testSubject = new MyTestSubject;
    }
    

    Now you still don't have the 'MyActiveRecord' object it is supposed to return in foo. Because you like Registry, your MyActiveRecord actually looks like this

    class MyActiveRecord
    {
        protected $db;
    
        public function __construct()
        {
            $registry = Registry::getInstance();
            $this->db = $registry->get('db');
        }
        public function findById($id) { … }
    }
    

    There is another call to Registry in the constructor of MyActiveRecord. You test has to make sure it contains something, otherwise the test will fail. Of course, our database class is a Singleton as well and needs to be reset between tests. Doh!

    public function setup()
    {
        Registry::reset();
        Db::reset();
        Registry::set('db', Db::getInstance('host', 'user', 'pass', 'db'));
        Registry::set('MyActiveRecord', new MyActiveRecord);
        $this->testSubject = new MyTestSubject;
    }
    

    So with those finally set up, you can do your test

    public function testFooDoesSomethingToQueryResults()
    {
        $this->assertSame('expectedResult', $this->testSubject->findById(1));
    }
    

    and realize you have yet another dependency: your physical test database wasn't setup yet. While you were setting up the test database and filled it with data, your boss came along and told you that you are going SOA now and all these database calls have to be replaced with Web service calls.

    There is a new class MyWebService for that, and you have to make MyActiveRecord use that instead. Great, just what you needed. Now you have to change all the tests that use the database. Dammit, you think. All that crap just to make sure that doSomethingWithResults works as expected? MyTestSubject doesn't really care where the data comes from.

    Introducing mocks

    The good news is, you can indeed replace all the dependencies by stubbing or mock them. A test double will pretend to be the real thing.

    $mock = $this->getMock('MyWebservice');
    $mock->expects($this->once())
         ->method('findById')
         ->with($this->equalTo(1))
         ->will($this->returnValue('Expected Unprocessed Data'));
    

    This will create a double for a Web service that expects to be called once during the test with the first argument to method findById being 1. It will return predefined data.

    After you put that in a method in your TestCase, your setup becomes

    public function setup()
    {
        Registry::reset();
        Registry::set('MyWebservice', $this->getWebserviceMock());
        $this->testSubject = new MyTestSubject;
    }
    

    Great. You no longer have to bother about setting up a real environment now. Well, except for the Registry. How about mocking that too. But how to do that. It's hardcoded so there is no way to replace at test runtime. Crap!

    But wait a second, didn't we just say MyTestClass doesn't care where the data comes from? Yes, it just cares that it can call the findById method. You hopefully think now: why is the Registry in there at all? And right you are. Let's change the whole thing to

    class MyTestSubject
    {
        protected $finder;
    
        public function __construct(Finder $finder)
        {
            $this->finder = $finder;
        }
        public function foo($id)
        {
            return $this->doSomethingWithResults(
                $this->finder->findById($id)
            );
        }
    }
    

    Byebye Registry. We are now injecting the dependency MyWebSe… err… Finder?! Yeah. We just care about the method findById, so we are using an interface now

    interface Finder
    {
        public function findById($id);
    }
    

    Don't forget to change the mock accordingly

    $mock = $this->getMock('Finder');
    $mock->expects($this->once())
         ->method('findById')
         ->with($this->equalTo(1))
         ->will($this->returnValue('Expected Unprocessed Data'));
    

    and setup() becomes

    public function setup()
    {
        $this->testSubject = new MyTestSubject($this->getFinderMock());
    }
    

    Voila! Nice and easy and. We can concentrate on testing MyTestClass now.

    While you were doing that, your boss called again and said he wants you to switch back to a database because SOA is really just a buzzword used by overpriced consultants to make you feel enterprisey. This time you don't worry though, because you don't have to change your tests again. They no longer depend on the environment.

    Of course, you still you have to make sure that both MyWebservice and MyActiveRecord implement the Finder interface for your actual code, but since we assumed them to already have these methods, it's just a matter of slapping implements Finder on the class.

    And that's it. Hope that helped.

    Additional Resources:

    You can find additional information about other drawbacks when testing Singletons and dealing with global state in

    • Testing Code That Uses Singletons

    This should be of most interest, because it is by the author of PHPUnit and explains the difficulties with actual examples in PHPUnit.

    Also of interest are:

    • TotT: Using Dependency Injection to Avoid Singletons
    • Singletons are Pathological Liars
    • Flaw: Brittle Global State & Singletons
    0 讨论(0)
  • 2020-11-30 03:20

    Singletons (in all OOP languages, not just PHP) make a particular kind of debugging called unit testing difficult for the same reason that global variables do. They introduce global state into a program, meaning that you can't test any modules of your software that depend on the singleton in isolation. Unit testing should include only the code under test (and its superclasses).

    Singletons are essentially global state, and while having global state can make sense in certain circumstances, it should be avoided unless it's necessary.

    0 讨论(0)
  • 2020-11-30 03:30

    When finishing a PHP test, you can flush singleton instance like this:

    protected function tearDown()
    {
        $reflection = new ReflectionClass('MySingleton');
        $property = $reflection->getProperty("_instance");
        $property->setAccessible(true);
        $property->setValue(null);
    }
    
    0 讨论(0)
提交回复
热议问题