Testing Microservices in PHP with Codeception

Docler
Docler
Aug 3, 2020 · 7 min read

Written by Michael Bodnarchuk @davert

Not all PHP applications can be developed as a monolith.
An application grows, as well as the engineering team around it, and at some point, in order to keep things consistent, a decision is made to split the application into microservices.

Microservices are similar to classes in PHP. They have public & private methods. External collaborators (other microservices) can call them via public API, while the internal state and private methods are hidden. Similarly to classes, each microservice should have its tests to ensure it does what it is expected to do and plays nicely with its collaborators.

Testing microservices can be tricky. Microservice A can call service B, which in its turn calls service C. If we tested classes of the same structure, we would have unit tests for A, B, & C, as well as an integration test which goes through all of them. Can we do the same for microservices? Well… Technically, yes. But there are a few things we need to understand before testing microservices. And the most important word here is

Request flow in microservices

Contracts

That’s right, ‘a contract’ stands for the structure of public APIs that each microservice exposes. When a certain microservice B has a contract, and it is often validated in compliance with it, then microservice B can rely on that contract. But what can be considered a good contract?

A good contract should be either based on API specification, usually OpenAPI or RAML, or alternatively, a consumer-driven contract with Pact should be used.

Let’s see what kind of contracts would be better for your particular case.

Consumer-Driven Contracts

If you don’t use OpenAPI or RAML specifications, you should consider using consumer-driven contracts. In this case, the behavior of microservices is defined by their actual usage.

Let’s say a certain microservice A makes a call to a certain microservice B. In this context, A is a consumer and B is a producer

The consumer dictates the behavior of the producer. No matter what, the consumer must receive the requested result it asked for. If you want to change that behavior, you either deploy a new version of microservices or create new microservices. However, you can’t change API of the producer without breaking the consumer calls. And because we are not even aware of what other microservices may depend on that producer, there is no good way of upgrading all APIs at once.

If a certain consumer is a boss here, we take its behavior as a contract. And here is how we can use that in tests. To test consumers and producers independently of each other, we introduce MockProvider in the middle. It is a mock server that records all requests coming from the consumer and sends a predefined mock response. Then it takes all known requests and responses and replays them towards the producer.

Data flow in consumer-driven tests

Thus, no matter how many consumers you have, you can be sure that the producer is tested by all of them. Unlike using a regular mocking server (like WireMock), here you can reuse the same data for both ends, i.e. even though each microservice is tested in isolation, its tests are not isolated, and that’s really awesome, as you can spare your integration tests the cost of real integration!

Although consumer-driven tests sound really cool at first, they require you a mock provider service to be added into your infrastructure and to keep all contracts stored globally, to be easily accessed to test any set of microservices. Dealing with different versions of microservices or resolving contract conflicts can also be problematic within big teams. So whenever your microservices do contain formal API specification, it’s better to turn it into a contract.

Specification-Driven Contracts

If each microservice has its own OpenAPI specification, its collaborators can use that contract to verify that the requests they send are correct. A mock server is still required for testing; however, each microservice can have its own mock server without a need to have global mock storage, as in the case with consumer-driven contracts. The most important point here is that all external requests must be handled by the mock server and verified via OpenAPI spec of the corresponding microservice.

The next diagram illustrates testing microservice A calls microservice B:

Flow in specification driven tests

In order to perform contract testing for A->B integration, all we need from B is its specification. So it’s quite important to have specifications of all possible collaborators. Those specifications must be used in tests so that no request would come from A unvalidated.

Unlike with consumer-driven contracts, we test only the structure of requests & responses without testing the real data. If you want to test the actual data passed from A to B, you should also consider consumer-driven contracts, as they use the same data in requests on both ends.

OK, that was a lot of theory, but what does Codeception have to handle all of this?

Contract Testing in Codeception

Unfortunately, there is no built-in module for contract testing in Codeception. However, depending on the type of test you choose, it is quite easy to develop helpers and share them across all teams. It is quite important to use a unified solution, and Codeception testing framework is a good solution, as it already provides flexibility and enterprise-grade functionality for all kinds of tests. And yes, the REST API testing module is built in! Which, btw will be heavily used for contract testing.

Pact Tests in Codeception

The main reason to use Pact for consumer-driven testing is its integration with PHP via the PactPHP library. Even though we can use it directly, it is recommended to create a custom helper to simplify the test code. To create a consumer-driven test we need to define a name, specify the provider it targets, ask to prepare data, and mock responses. This is how it can be used in Codeception tests:

$I->haveRequestTo('api', 'get user names')
->requires('one user')
->request('GET', '/my-request')
->response(200, 'ok');
$I->sendGET('http://0.0.0.0:7800/my-request');

A complete implementation of PactHelper can be found at this link.

If you are interested to take this helper and publish it on Packagist, you are highly welcome to do so. However, consider this not as fully working solution, but rather as a prototype. So try it out in your project and improve it before publishing.

Alternatively, you can use Pact extension for Codeception developed by Tien Vo Xuan. It is a more complete implementation than the one listed above.

Pact requires to run its mock server and a broker (contracts storage). Integrating them into your infrastructure and transforming a test process is a challenge out of the scope of this post.

OpenAPI Tests in Codeception

We highly recommend using the openapi-psr7-validator library to perform specification driven contract testing in PHP. To start, specification files are required, as well as a mocking server to replace dependent microservices. As it was previously shown on the diagram, we prepare responses according to the OpenAPI specification, then call a certain microservice A, and then we check that it actually performed a request to certain microservice B. That request must also comply with the specification. The code of a sample test could be like this:

$mock = $I->haveValidMock('B','/microservice-b/endpoint', $mockData);
$I->sendGET('/v1/users');
$I->seeMockWasRequested($mock);

In this test, we perform GET request to /v1/users endpoint of current microservice and we expect that URL /microservice-b/endpoint of the microservice B was actually called. We also validate the current request to B and mocked response in our test via OpenAPI spec.

This test requires a custom helper to be created. Here is what it should contain:

  • specfication of microservices must be loaded in _before hook
  • haveValidMock method should create a mock response for endpoint and validate $mockData to match response spec
  • seeMockWasRequested method calls mock server to check that previously mocked request was used and that the performed request actually matches B spec.

Conclusion

To ensure all microservices play well together, they have to be tested. The most effective way is to use contract testing, which ensures that each service works in isolation but sends correct requests to its peer. Depending on your circumstances you can either go with Consumer-Driven contracts & Pact Framework or use OpenAPI for Specification-Driven contracts.

Both options require setting up an additional infrastructure layer on top of your system to mock requests and check requests against mocks. There is no built-in solution for that, as each company has its own policy on building microservices. That’s why in this post we only listed common solutions that can be implemented with Codeception, as well as described how contract testing works.

Docler Engineering

Tech articles from the engineers building one of the most visited websites in the world.

Docler Engineering

Get insights from Docler Engineering teams on how we are solving different challenges in live streaming, software architecture, infrastructure and technical management.

Docler

Written by

Docler

Curious about the technologies powering the 30th most visited website in the world, with 45 million users, 2,000 servers, 4 data centers?

Docler Engineering

Get insights from Docler Engineering teams on how we are solving different challenges in live streaming, software architecture, infrastructure and technical management.