Swapping instances in the object manager

Magento 2, Testing

Coming up next week is MageTestFest. For the last ~2 years I have been doing a lot with testing and Magento, and I think this is awesome. In that spirit I decided to write this blogpost down.

The problem

Recently I was working on a Magento 2 project which did api calls to an external service. I wanted to test my code, but when it was executed it would make an api call. I wanted to mock the api call to make sure I would get always the same output. The problem was: It was 5 or 6 levels deep before the api was called. It was something like this:

Controller -> RetrievePdf -> RetrieveFromApi -> ApiBuilder -> ActualApiCall

Now the Object Manager treats the objects it creates as singleton when you use the ->get() method. So in theory we could create a mock, call the ->get() method for the ApiConstructor and inject our mock. But this was a recurring problem so I wanted a better solution.

MageTested.com (ad)

Do you want your Magento store to be more reliable? Tired of things that suddenly break the checkout process without anyone noticing? You can hire my services to kickstart End-2-End testing for your Magento store. This way you know for sure that your store is behaving as expected before releasing that new feature or that update.

View MageTested.com for more information.

Unit vs integration test

So before we can continue, you must understand that there are multiple object managers in Magento 2. In your main code is it common practice to never use the Object Manager, you get all your classes by DI, Dependency Injection. But in your tests it is fine to use the object manager.

In Unit tests, you can use new \Magento\Framework\TestFramework\Unit\Helper\ObjectManager($this); to retrieve it. When you use ->get() to retrieve an object, all dependencies will resolve to a mock whereby all methods return null by default.

In Integration tests this is different. Here you can use \Magento\TestFramework\ObjectManager::getInstance(); to retrieve an instance of the object manager. There is also an other difference: If you load a class using ->get() all the dependencies will be resolved to the actual dependencies.

Swapping classes

This idea popped up as when I was writing tests with Laravel. There you can simple call app()->instance(TheActualClassName::class, $youMock); (You can check this page in the Laravel docs to check it out. Scroll to Binding Instances). When using the container correct you are able to switch classes in runtime, making testing a lot easier. As far as I know, this is not possible in Magento 2.

So I started playing with the object manager. First I inspected it's source. After some hacking I finally did find a way to achieve this result. It was something like:

  • Create the of the class

  • Call the ->configure() method of the object manager with this array:
    NameSpaceToTheOriginal::class => get_class($mock)

  • Retrieve the "original" class using the ->getObject() method of the object manager, which returned a new instance of the mock.

  • Set the expectations and methods on the mock.

This worked, but it did feel like hacking and using the object manager in a wrong way.

Meet addSharedInstance

While writing this article I took a deeper dive in the object manager. Apparently I was looking at the wrong object manager. The trick above works for the regular object manager, but the object manager used in the integration test has a really nice method: ->addSharedInstance(). Internally the object manager has an array which holds the instances of all classes it has loaded. When you request a new object, it first will lookup in this array if it is created already. If not, it will create it and add it to this array. The ->addSharedInstance() method adds an instance to this array. So when requested an object, it checks it  and find our instance which prevent the creation of the original class.

So how can I use this? Simple, just call it this way:

$this->objectManager->addSharedInstance($mock, NameSpaceToYourCustom::class);

Anytime this class is requested from the object manager (eg by dependency injection) it will return your custom mock now. Which is awesome.

Want to respond?