donatj / mock-webserver

Simple mock web server in PHP for unit testing.
MIT License
131 stars 21 forks source link

Mocking a remote service in an e2e test #50

Closed davidyell closed 1 year ago

davidyell commented 1 year ago

I have been asked to create an end to end test which covers an endpoint. This endpoint is a proxy for another web service.

I am trying to use this package to mock the remote web service responses, so that I can create a test for the various http codes the service it calls can return.

For some reason, when I set a response for a path, and then call my services endpoint I do not get any body back to assert against.

My test setUp creates an instance of the server, running on the same port as my remote service, localhost:5100 so I was expecting, perhaps incorrectly, that when my e2e test called the endpoint url, the code there would try and get a response from the remote service and instead hit the mock web server on localhost:5100 instead, and then return an instance of donatj\MockWebServer\Response instead of GuzzleHttp\Psr7\Response as that is what I configured on my $this->mockServer->setResponseOfPath().

I am really struggling to understand why I can build a stub response, set it to the path, but when my code under test calls the url, the returned response is a Guzzle one with no body.

davidyell commented 1 year ago

Seems that if you pass query params you don't get a response. If you do not include the query params when mocking the path, it works fine.

Whaddya know!

donatj commented 1 year ago

I don't understand the problem you're talking about

Given my example code here

<?php

use donatj\MockWebServer\Response;

require 'vendor/autoload.php';

$server = new \donatj\MockWebServer\MockWebServer;
$server->start();

$endpoint = $server->setResponseOfPath('/path', new Response('Hello World', [], 200));

echo file_get_contents($endpoint . '?getParam=chicken&getParam=egg');

I get back my defined 'Hello World' regardless of the defined GET parameters, which is expected?

davidyell commented 1 year ago

I will try and explain with a cut down example of code, but it is essentially a Guzzle request to another service I'd like to mock.

The end to end test looks like this.

    public function testWithAService500Response()
    {
        $responseData = [
            'errors' => [
                'source' => ['type' => 'unhandled'],
                'language_key' => 'Rude alert! Rude alert! An electrical fire has knocked out my voice recognition unicycle! Many Wurlitzers are missing from my database.',
                'detail' => [
                    'info' => 'ParseError',
                    'line' => '9001',
                    'file' => '/var/www/AnExampleFileWithAnError.php'
                ]
            ]
        ];

        $stubServiceResponse = new StubResponse( // This is the package's response class
            body: \Safe\json_encode($responseData),
            headers: ['Content-Type' => 'application/vnd.api+json'],
            status: 500
        );

        $filter = ['filter' => ['occasion_id' => '6e6c85d7-20d5-485a-80bd-7796ff8d2898']];

       // Copy pasta from the instance in the parent test class `setUp()`
        $this->mockServer->setResponseOfPath(
            sprintf('/api/v1/claims/total-approved?%s', http_build_query($filters)),
            $response
        );

        // This sends a request to the application, a full end to end request/response
        $this->get(sprintf('/v1/claims/total-approved?%s', http_build_query($filter)), 400);
    }

NB The urls are similar! http://localhost/v1/claims/total-approved vs http://localhost:5100/api/v1/claims/total-approved the port 5100 is the remote to be mocked.

Then in the application code, it has to send a request to another service, which is using Guzzle. As you can see it just proxies through what it gets from the test.

$response = $this->get('/api/v1/claims/total-approved', [RequestOptions::QUERY => ['filter' => ['occasion_id' => $filter->occasionId]]]);

The assertion of the response from the application, which should be a 400 in this case, as I wrap the failed remote service call 500 :) Bit confusing I know.

So my expectation is that when the application sends a request to the remote service, that it should return the configured Response object. However I always get back a 200 OK and I have no clue why that might be.

I wondered if it was the query params, but I was wrong.

The main difference in this use-case to those in the README file is that they call the mocked service directly, whereas this is expecting the code to call localhost:5100 but instead of the Docker container responding (it's off during test) the mock webserver would instead reply with the configured response.

I am really struggling, in the application code, to catch the response 500 and have the code manage it.

I appreciate it seems a complex example, and I hope I've explained it šŸ˜ The package was suggested by a colleague, so it's new to me just this morning šŸ˜…

donatj commented 1 year ago

Am I reading right that you're mocking one path yet calling a path you didn't mock?

mocked: /api/v1/claims/total-approved?%s not mocked:: /v1/claims/total-approved?%s

I don't know what you'd expect to happen, as the path you're calling isn't defined - calling an unmocked path by default returns 200 and the content of the request as a JSON body, which is probably what you're seeing since you didn't mock the path you're calling.

You can see an example of that by running

<?php

require 'vendor/autoload.php';

$server = new \donatj\MockWebServer\MockWebServer;
$server->start();

print_r(
    file_get_contents($server->getServerRoot() . '/unmocked-path')
);

You can disable that default behaviour by changing the default response ala

<?php

use donatj\MockWebServer\Responses\NotFoundResponse;

require 'vendor/autoload.php';

$server = new \donatj\MockWebServer\MockWebServer;
$server->start();
$server->setDefaultResponse(new NotFoundResponse);

print_r(
    file_get_contents($server->getServerRoot() . '/unmocked-path')
);
donatj commented 1 year ago

Also, you shouldn't be including the GET parameters as part of the "path" at all, per the spec of URI path does not include the query

image
davidyell commented 1 year ago

Am I reading right that you're mocking one path yet calling a path you didn't mock?

Exactly that, yes, the unit of code under test calls the mocked code.

  1. I mock the path that the code calls.
  2. I send a request to the api endpoint which executes that code
  3. I assert that the response I get back from the unit of code under test matches expectations

So I am hoping to plumb the mock in by simply matching it's host and port to the one the code under test calls.

Also, you shouldn't be including the GET parameters as part of the "path" at all

Yes, that was what I realised and closed the issue as I felt it might have been the route cause of my issue. However I do call the same path in the 3 tests in this test-case file. So hopefully the response instance can be replaced each time?

So in summary is this not an appropriate library for my use-case? I did look at https://wiremock.org/ before this library was suggested by my colleague šŸ˜„

donatj commented 1 year ago

I've been over this thread a number of times trying to get my head around what you're trying to do - this is what keeps throwing me here:

// This sends a request to the application, a full end to end request/response
$this->get(sprintf('/v1/claims/total-approved?%s', http_build_query($filter)), 400);

What is this doing exactly? Is it triggering some sort of API connector to make a GET request, or is it triggering a controller in some sort of application?

The comment leads me to believe the latter?

davidyell commented 1 year ago

The end to end test sends a request to an endpoint built in a Symfony application.

This triggers a controller, service class, etc. Part of that feature is that it calls another api to get some data. Which it then returns as it's api endpoint response. Hence acting as a proxy.

When the test of this code runs, that remote api doesn't exist. It's just a host and port. So that needs to be mocked.

So when the Symfony class sends it's Guzzle request out to the api endpoint, instead of hitting the remote api, it gets a pre-defined response mock from the Mock Web Server, which is defined in the test.

So the code under test just calls localhost:5100 and when the remote service is up and running in prod for example, it'll return it's data, but in the test pipeline where that service doesn't exist. I was hoping that his package could respond to that request, by configuring the mock web server to respond to localhost:5100 with a fixed mock response instead, to simulate the missing remote api.

It's an odd implementation for sure!

donatj commented 1 year ago

OK! So that clears it up greatly - thank you!

The similarity in the naming just really threw me for a loop šŸ˜†

So then /v1/claims/total-approved is the path to your controller, and /api/v1/claims/total-approved is the api endpoint you are attempting to mock. I think I'm with you now, lol. Sorry.

So here's a little working example of what I think you're looking to do:

<?php

use donatj\MockWebServer\Response;

require 'vendor/autoload.php';

$server = new \donatj\MockWebServer\MockWebServer(5100);
$server->start();

$responseData = [
    'errors' => [
        'source'       => [ 'type' => 'unhandled' ],
        'language_key' => 'Rude alert! Rude alert! An electrical fire has knocked out my voice recognition unicycle! Many Wurlitzers are missing from my database.',
        'detail'       => [
            'info' => 'ParseError',
            'line' => '9001',
            'file' => '/var/www/AnExampleFileWithAnError.php',
        ],
    ],
];

$endpoint = $server->setResponseOfPath(
    '/v1/claims/total-approved', new Response(json_encode($responseData))
);

your_controller();

$request = $server->getLastRequest();

echo 'raw body:    ' . $request->getInput() . PHP_EOL . PHP_EOL;
echo 'parsed body: ' . var_export($request->getParsedInput(), true) . PHP_EOL;

// below is an API request representing your controller/api connector for this example

function your_controller() : void {
    $postdata = http_build_query(
        [
            'param1' => 'some content',
            'param2' => 'doh',
        ]
    );

    $opts = [
        'http' =>
            [
                'method'  => 'POST',
                'header'  => 'Content-Type: application/x-www-form-urlencoded',
                'content' => $postdata,
            ],
    ];

    $context = stream_context_create($opts);
    $result  = file_get_contents('http://127.0.0.1:5100/v1/claims/total-approved', false, $context);
}

Outputs

raw body:    param1=some+content&param2=doh

parsed body: array (
  'param1' => 'some content',
  'param2' => 'doh',
)
davidyell commented 1 year ago

This is almost exactly it. The odd thing is that once the Symfony application code makes a request using Guzzle to the mocked endpoint, no further code in that function is executed.

I can dd() in the top of the method and get an output, but if I add dd() after the call to guzzle $this->guzzle->get($microserviceUrl) I get no output.

I think this is the core of my issue, as the function needs to react to the microservice response status code.

function fetchDataWithGuzzle() {
    $response = $guzzle->get('/api/v1/claims/total-approved');
    dd($response); // No output if the above endpoint is mocked

    if ($response->getStatusCode() !== 200) {
        throw new MicroserviceException('Something fell over', $response->getStatusCode());
    }

    return $response;
}

It feels like perhaps the mock server is hooking the call, and then not returning back to the calling context to continue execution. If I comment out the the setResponseOfPath() in the test, I get output from dd() šŸ˜„

davidyell commented 1 year ago

I have had some time to work through this issue with my colleague and we have discovered a bug in our application code. We have an exceptions listener which is catching the mocked response exception and handling it.

This means that my feature code to handle the response, never gets called.

Thanks for the help, but I will close this issue now as we've proved this a userland issue šŸ˜„