httptoolkit / mockttp

Powerful friendly HTTP mock server & proxy library
https://httptoolkit.com
Apache License 2.0
778 stars 88 forks source link

Question: How to mock api calls in parallel in tests? #109

Closed samatar26 closed 3 months ago

samatar26 commented 1 year ago

Hellooooo 👋🏾

I'm really liking the library so far, really amazing work!! 💯

I've been using it combination with Playwright and Next.js and seem to be stumped by mocking server side requests in parallel as the tests will be running in parallel. The api url I want to mock is http://localhost:3005/api and I think this would mean I would need to start a mockServer on port 3005 blocking me from running the tests in parallel.

I could change the api url, but not entirely sure to what (I think to a non-localhost one? but struggled to intercept the request) and was wondering if you had any ideas/suggestions to enable multiple mockServers or maybe even 1 mockServer but it somehow magically knowing where the request came from and replying with the correct body/mock specified in the test?

Thank you so much for looking into this, really appreciate it!!

pimterry commented 1 year ago

Hi @samatar26! :wave:

I'm afraid there's no easy single answer, but there's a lot of options. In practice, if you want mocks for independent tests, the best way is definitely to run a separate Mockttp instance for each test.

That means either:

Depends on your setup, but particularly how easy it is for you to inject API config into the code your testing. If you can easily pass that, then the first option will be easy and that's the best approach. If not, you'll need to find a way to set different browser config to make that work.

For actual proxying, with Chrome, you just need to set the --proxy-server=localhost:$PORT with your Mockttp server's port, and then you can mock any URL like mockServer.forGet('http://example.com/api').thenReply(404).

If you're intercepting HTTPS too, you'll also need to set --ignore-certificate-errors-spki-list=$CERT_FINGERPRINT with the fingerprint of your cert. Mockttp exports a generateSPKIFingerprint(certPEMString) method to calculate this for you.

There's an intro to proxying traffic with Mockttp more generally (not for testing specifically) over here: https://httptoolkit.com/blog/javascript-mitm-proxy-mockttp/

Does that make sense? There's no clear answer but Mockttp provides a few different ways you can do this depending on exactly how your setup works.

samatar26 commented 1 year ago

Thanks for responding @pimterry, much appreciated! I don't think the solutions you've provided will work for me, but it could also be that I'm just not fully getting it sorry. I think I should describe my setup in a little bit more detail to see if it still makes sense for you:

The test will open a browser, navigate to the login page, say localhost:8000/login and the browser will first hit the Next.js server. And this Next.js server can also potentially make a network request. Intercepting network requests from the browser is no problem as the test framework provides some helpful methods to mock the requests. The real fun starts with intercepting a request coming from the app's server.

What I've done so far is start a mockttp server and run it on the same port as the Next.js API_URL, let's say localhost:8888. This works fine and allows me to mock the Next.js server side requests, but only when I run the tests in sequence.

In order to run the tests in parallel, I think I need to run several mockttp instances (1 for each test?) and allow mockttp to choose a port at random and am struggling to think of a way to make this work. Let me know if I'm making any sense at all 😛 and thanks again for helping/looking into this!

pimterry commented 1 year ago

I think I need to run several mockttp instances (1 for each test?) and allow mockttp to choose a port at random and am struggling to think of a way to make this work

Yes, exactly that - you'll need to create a separate Mockttp instance for each test, start it, and then pass the port that instance starts on to your server somehow.

Unfortunately, given this constraint:

I have one instance of a Next.js app running, so 1 server + frontend

I think you're stuck. If you have one instance of the app running with parallel tests, all requests are all going to end up doing the same thing.

Without a mechanism to tell from a server-side request which test it's related to, you're not going to be able to handle the requests independently for each test.

If you want parallel testing in this setup with different Mockttp configuration for each test, you need to either:

markbrockhoff commented 3 months ago

Hi @pimterry, I might be a little late to the party but just encountered the same problem. The difference is: As I'm using Nuxt (similar to Next but for vue) together with playwright, I basically have one Nuxt instance (frontend and server) per test. My idea right now would be to use the port the request is comming from to identify which mock to use. This way it should be possible to use one mock server for all parallel tests as they would be differentiable by the requests remote port.

I can do so by writing something like this:

await mockServer
    .forGet("/test")
    .matching((req) => req.remotePort === 1234) // "1234" is just an example here, in reality this is a variable changing for each test
    .thenReply(200, "Playwright");

But as I'm trying to abstract this logic away inside a library it would be nice if I could set the "matching" rule as a default for the entire RuleRequestBuilder. So that I can add the matching rule once and then just continue writing rules without worrying from where they're comming.

What do you tink, would something like this be possible? (Maybe it is already and I overlooked it) I'd imagine some functionality to create a RuleRequestBuilder from a server and setting some default conditions on it before handing it to my test. For example:

import mockttp from 'mockttp';

const mockServer = mockttp.getLocal();
await mockServer.start();

const builder = mockServer.createRequestBuilder({ onRequest: (request) => {
    // This condition will run before every rule of the builder. Similar to the "matching" condition only requests returning true will be handled
    return request.remotePort === 1234;
}})

Then inside the test:

test("whatever", async ({ builder }) => {
    await builder.forGet("/test").thenReply(200, "Playwright"); // Will only match requests where the remote port is "1234"
})
pimterry commented 3 months ago

Hi @markbrockhoff. You're welcome to try that, but how are you going to control which port the outgoing request is sent from? That's certainly not something browsers will let you reliably control, and even doing this on the backend is non-trivial and quite unusual. I think you'll struggle! If you do have a way to get this working well for this scenarios though, I'd love to hear more.

For your specific question about builders, that's not supported, but for advanced unusual cases like this, I'd suggest you skip the builder API entirely. If you look at the source for the request-rule-builder and base-rule-builder you can see how the rule definition is actually created - you can do the same thing yourself (which whatever API and predefined data you like) and then pass the rule data directly to server.addRequestRules to create those rules (you can follow the types from the docs there to see more detail about what a rule-data object looks like).

More generally though: I'm not sure there is a reliable path through this remote port mechanism. If you're running parallel tests against a single instance of your application, I think you will always need some way to pass information from the test down to the HTTP client part of your code (e.g. an env var, global window state etc) such that the request can be sent differently (to a different target port, or with a recognizable extra header or similar) so the Mockttp server can handle it appropriately. There must be some recognizable & reliable difference between the requests sent by different tests from your single instance, so i think the only option really is to somehow pass that data through (or give up on parallel testing, or run multiple parallel instances of your app).

markbrockhoff commented 3 months ago

Hi @pimterry thanks for the quick reply.

Yeah I already encountered this issue yesterday after writing the message. However I was able to resolve it by using the origin header of the request. As the url for the tested nuxt app will be the same for the browser as when making requests from the backend, that turned out to work pretty reliably.

I also looked at the rule builder trying to figure out if it would be possible to add something like I proposed, however I think I came to the same conclusion as you. It could work but would most likely just cause more issues in the long run. For example when resetting the mocks of the mock server as only the ones responsible for the current test would need to be reset and so on. That's why I shifted my approach a bit today. As this amazing mock server starts up in less than 10ms I now just spin up one mock server per app instance. Using a little trickery within the playwright tools of nuxt I was able to inject the url of the mock server into that without touching any implementation details of the actual app. (Basically it's like overwriting the env var for the server url with the one of the mock server)

This way I can be sure that no test can influence the other at the little cost of ~8ms more startup time, which is neglectable considering the app startup itself takes ~5s.

So thanks a lot for your input and of course this amazing library. It simply does exactly what I needed 👍

pimterry commented 3 months ago

Ok great! Glad that's working well for you now. Yes, in general Mockttp is designed to start & stop very quickly and run as a very lightweight fast server, so running a new instance in parallel for every test should be good reliable approach.

I'm going to close this issue, since I think the discussion here covers the options and recommendations pretty well already, but do please open a new issue if you (or anybody else) is still running into problems.