Hi!
I’d like to answer this question by framing it differently: not how to “mocking Tesla” but about how to define our Requests domain and create a boundary around it.
I like how you used the word abstraction, because that’s exactly what we’ll address here.
In order to do so, I wanted to add some context I find extremely important when discussing these topics. It’s the three categories that code falls under in (Grokking Simplicity
)[Grokking Simplicity]. This book is such a fantastic resource by the way, I highly suggest getting a copy. There, Eric Normand mentions the three categories functional programmers group code:
- Actions
- Calculations
- Data
This technique works with whatever Action
you are dealing with. On page 10, the author defines an Action
as:
- Anything that depends on when it is run, or how many times it is run, or both, is an action. If I send an urgent email today, it’s much different from sending it next week. And of course, sending the same email 10 times is different from sending it 0 times or 1 time.
In my humble attempt (the book does a better job at this), I summarize them as:
- Actions - things that make stuff happen, you don’t “own”, potentially hard to test, could break in prod while all other tests pass
- Calculations - Input in, output out, always the same result. The more of these, the better.
- Data - what we operate on, the ingredients in all of this
HTTP requests fall in that Actions category. In the very least, if you send a request to an api and they are down shows that it is not a calculation. This is when you start to see an opportunity to create a boundary around this part of the code. If you dig a little deeper, you may see the abstraction and be able to create a “domain”.
Actions give you hints about your domain and where to set your boundaries. In Elixir/Phoenix land, we have great tools to deal with these boundaries:
- behaviours
- implementations
- Phoenix Contexts (Contexts — Phoenix v1.7.10)
Contexts are an excellent way to think about this. It’s such a fantastic approach. I don’t think there is a mention of the word domain in the Phoenix Contexts docs, but we should likely try to add it. Maybe it’s done on purpose to not get so academic about it and avoid bikeshedding. But I personally see Contexts and “domains” as somewhat interchangeable here.
Now, with all of this said, we can put everything together:
- Your domain is “HTTP requests” in this case
- Create a context module called
Requests
orHTTPRequests
- Create a behaviour: This is personal preference, but I prefer appending behaviour to Behaviour modules and also adding it to file names. I’ve found it to help folks navigate the code better, especially less senior folks. In this case:
RequestsBehaviour
- Create an implementation (impl) that implements the behaviour:
RequestsTeslaImpl/RequestsReqImpl
(I like Req ) - The
Requests
module chooses which impl to pick (you can have multiple… this is what makes this important: you can switch them as you need. - Set up Mox for that behaviour
- In your tests, you can enjoy Mox, add your expects as usual
Let’s walk through it:
# The context
# lib/my_app/requests.ex
defmodule MyApp.Requests do
def get(args) do
impl().get(args)
end
# this is the switch that chooses which impl to use at runtime
defp impl do
Application.get_env(:my_app, :requests_impl)
end
end
# The behaviour
# lib/my_app/requests/requests_behaviour.ex
defmodule MyApp.Requests.RequestsBehaviour do
@callback get(map()) :: {:ok, map()} | {:error, map()}
end
# The implementation (impl)
# lib/my_app/requests/requests_hackney_impl.ex
defmodule MyApp.Requests.RequestsHackneyImpl do
@behaviour MyApp.RequestsBehaviour
@impl true
def get(args) do
args
|> :hackney.get()
# you can normalize the output here. Seems like it returns a tuple
# rather than an {:ok, map()}, which the others are all standard
|> case do
{:ok, _status, body} -> {:ok, %{body: body}}
error -> error
end
end
end
Then, you can set up Mox. The readme should get you going, but let’s add the steps that leverage config
here as well:
# in test/test_helper.exs
Mox.defmock(RequestsBehaviourMock, for: MyApp.RequestsBehaviour
# config/config.exs
config :my_app, :requests_impl, MyApp.Requests.RequestsHackneyImpl
# config/test.exs
config :my_app, :requests_impl, RequestsBehaviourMock
Sorry I didn’t address your problem at hand (dealing with the Tesla.Mock
thing). It felt a little bit like an XY problem to me and a chance to address something I see often.
Another thing that I want to address is that the example above uses hackney
. That’s because I didn’t write the above today, I wrote it to a friend of mine that asked me about this just last week. I think that once you approach the problem the way I mention above, things become much easier to solve. In your case, with Tesla, the MyApp.Requests.RequestsHackneyImpl
would likely look like:
# The implementation (impl)
# lib/my_app/requests/requests_tesla_impl.ex
defmodule MyApp.Requests.RequestsTeslaImpl do
@behaviour MyApp.RequestsBehaviour
@impl true
def get(args) do
Tesla.get(args)
end
end
Side note: Once you have the set up with the behaviours and such, it should be straight forward to move to Req
Side note 2: I also don’t mean to diss/put down the current approach. I’m not versed in that library, maybe others can chime in to help. It’s a non-issue when approaching the problem as above. I hope my answer is not seen this way.
Hope this helps!