csernazs / pytest-httpserver

Http server for pytest to test http clients
MIT License
209 stars 27 forks source link

Add verification for calls recieved #293

Closed adamdougal closed 4 months ago

adamdougal commented 5 months ago

Heya!

Following on from my previous issue inspired by WireMock, another feature that I've always found useful is being able to verify the calls that have been made to the mock server. There are two main reasons why I think this is useful:

  1. You can verify calls were made N number of times. At the moment I believe you can do pretty good request matching when priming responses, but that doesn't allow you to verify that a call was actually made.
  2. It is useful to be able to do loose mocking with strict verifying. For example, only matching on path and method when priming and then verifying the full request including body, headers etc. This gives more specific failure reasons then perhaps an exception being thrown by the client library e.g. expected request with this body but got this other body.

I think this could be implemented by introducing a new RequestMatcher object, for example:

test_downstream_call_was_made():
  httpserver.expect_request("/foobar").respond_with_json({"foo": "bar"})

 # CALL CODE UNDER TEST

  httpserver.verify_request_made(RequestMatcher(
    url="/foobar", 
    method="POST", 
    json={"colour":"red"}, 
    headers={"content-type": "application/json", 
    times=3))

Wiremock docs for reference: https://wiremock.org/docs/verifying/

Again, happy to contibute where I have time if you're happy with this proposal.

Thanks!

adamdougal commented 5 months ago

I've got this implementation that I'm using in my tests as an example implementation:

import logging
from pytest_httpserver import HTTPServer

class RequestMatcher:
    path: str
    method: str
    json: dict
    headers: dict
    query_string: dict
    times: int

    def __init__(self, path: str, method: str, json: dict = None, headers: dict = None, query_string: str = None, times: int = 1):
        self.path = path
        self.method = method
        self.json = json
        self.headers = headers
        self.query_string = query_string
        self.times = times

    def __str__(self):
        return f"Path: {self.path}, Method: {self.method}, JSON: {self.json}, Headers: {self.headers}, Query String: {self.query_string}, Times: {self.times}"

def verify_request_made(mock_httpserver: HTTPServer, request_matcher: RequestMatcher):
    requests_log = mock_httpserver.log

    similar_requests = []
    matching_requests = []
    for request_log in requests_log:
        request = request_log[0]

        if request.path == request_matcher.path:
            similar_requests.append(request)

            if request.method != request_matcher.method:
                continue

            if (request_matcher.json is not None and request.json != request_matcher.json):
                continue

            if (request_matcher.headers is not None):
                for key, value in request_matcher.headers.items():
                    if request.headers.get(key) != value:
                        continue

            if (request_matcher.query_string is not None and request.query_string.decode("utf-8") != request_matcher.query_string):
                continue

    error_message = f"Matching request found {len(matching_requests)} times but expected {request_matcher.times} times. \n Expected request: {request_matcher}\n Found similar requests:"
    if len(matching_requests) != request_matcher.times:
        for request in similar_requests:
            error_message += "\n--- Similar Request Start"
            error_message += f"\nPath: {request.path}, Method: {request.method}, Body: {request.get_data()}, Headers: {request.headers} Query String: {request.query_string.decode('utf-8')}"
            error_message += "\n--- Similar Request End"

    assert len(matching_requests) == request_matcher.times, error_message

I've not spent any time refactoring this code though so it could definitely be improved!

csernazs commented 5 months ago

hi @adamdougal ,

Thanks for this! I think this would be a valuable addition to pytest-httpserver, I'll try to create a PR on the weekend for this. Also thanks for the example code, I think it helps a lot.

Zsolt

csernazs commented 5 months ago

@adamdougal Added the POC for this, could you take a look at #294 ?

I thought we could re-use the existing matchers and run a match on them. This would nicely re-use the existing matching logic. Also, httpserver's create_matcher method can be used to create a matcher object (or it can be created by the constructor as well).

What do you think?

I'm thinking about making some useful message for the assert but I have no idea at the moment so I left it without any assertion message.

adamdougal commented 5 months ago

Heya! Makes complete sense to reuse what's there.

Re the error message, you can see what I did around similar requests above, which really aids debugging failures. But obviously that would required some changes to the current matching code, I guess to achieve the same you could do an initial match just on path?

I've had a look at the PR and it all looks great! Thanks for getting this raised so quickly!

csernazs commented 5 months ago

Ah, I totally missed that similar requests logic in your code :facepalm:

...which is actually a very good idea, so I added those to the PR. Thanks! :+1:

See the error message in the tests.

What do you think? Should we change the error msg in some way?

Zsolt

adamdougal commented 4 months ago

This looks perfect to me!

adamdougal commented 4 months ago

Thanks for getting this implemented so quickly!

csernazs commented 4 months ago

hi @adamdougal ,

Thanks!

I'm thinking about making a release but probably together with #290, but that will take a few days given my limited free time.

What do you think?

Or, if you prefer, I can publish a release with only this feature.

Zsolt

adamdougal commented 4 months ago

Heya, no rush from my side. We have the solution I referenced above in place so we're good for now. Thanks!