External service testing in Phoenix

Getting Started

Before going to the detail, let’s me share a little bit about our system - we’re using Elixir-Phoenix framework to build a backend system and from the requirement, we need to build an API that can support our front-end client (ReactJS/React-Native) upload files to AWS_S3.

In Phoenix framework, we used an AWS client’s hex package called ex_aws to upload files to S3. Basically, the controller code will be:

1
2
3
4
5
unique_filename = "#{UUID.uuid4(:hex)}_#{filename}"

{:ok, image_binary} = filepath |> File.read()

Application.get_env(:my_app, :image_bucket_name) |> ExAws.S3.put_object(unique_filename, image_binary) |> ExAws.request!()

ExAWS.request!() will return the status_code is 200 if uploading is successful, otherwise, it will return another status_code.

Uploading module

As usual, we moved uploading code from UploadController to a UploadService module - this will make the controller looks more readable and easy to write the test.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
defmodule MyApp.UploadController do
use MyApp, :controller

@upload_service Application.get_env(:my_app, :upload_service)
import UploadService

def upload_image(conn, params) don
case upload(params) do
{:ok, filename} ->
json(conn, %{url: resolve_url(filename), error: nil})

{:error, reason} ->
json(conn, %{url: nil, error: reason})
end
end
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
defmodule UploadService do
def upload(params) do
%{"file" => %{filename: filename, path: filepath}} = params
unique_filename = "#{UUID.uuid4(:hex)}_#{filename}"
{:ok, image_binary} = filepath |> File.read()

case Application.get_env(:my_app, :image_bucket_name)
|> ExAws.S3.put_object(unique_filename, image_binary)
|> ExAws.request!() do
%{status_code: 200} ->
{:ok, unique_filename}

_ ->
{:error, "can't upload"}
end
end
end

Write test for Uploading API

When integrating with external services we want to make sure our test suite isn’t hitting any 3rd party services. Our tests should run in isolation. ThoughBot

With our UploadService module, we don’t need to test the request to S3 because the package itself already test. So, we only need to mock module to return ok or error response.

Setup the corresponding modules for different environments

The development and production environment will use the real UploadService and test environment will use the UploadService.Mock.

1
2
3
4
5
# dev.exs and prod.exs
config :my_app, :upload_service, UploadService

# test.exs
config :my_app, :upload_service, UploadService.Mock

And then, we changed a bit in the controller to dynamic loading the corresponding modules.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
defmodule MyApp.UploadController do
use MyApp, :controller

@upload_service Application.get_env(:my_app, :upload_service)
@upload_image_token Application.get_env(:my_app, :upload_image_token)

def @upload_image_token.upload_image(conn, params) don
case upload(params) do
{:ok, filename} ->
json(conn, %{url: resolve_url(filename), error: nil})

{:error, reason} ->
json(conn, %{url: nil, error: reason})
end
end
end

Next, we will create UploadService.Mock module.

Create a Mocking module

1
2
3
4
5
6
7
8
9
10
11
12
13
defmodule UploadService.Mock do
def upload(%{"file" => %{
"filename" => "success", "path" => _path
}}) do
{:ok, "your-file.png"}
end

def upload(%{"file" => %{
"filename" => "fail", "path" => _path
}}) do
{:error, "can't upload"}
end
end

We used pattern-matching with different filename to return ok or error.

Write the test

And now the test will not difficult to write.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
test "uploads success", %{image_token: image_token, conn: conn} do
conn = put_req_header(conn, "authorization", image_token)
file = %{
"filename" => "success",
"path" => "/your/image/path"
}
response =
post(
conn,
"/upload",
%{
"file" => file
}
)

assert %{"error" => nil, "url" => _url} = json_response(response, 200)
end

test "uploads fail", %{image_token: image_token, conn: conn} do
conn = put_req_header(conn, "authorization", image_token)
file = %{
"filename" => "fail",
"path" => "/your/image/path"
}
response =
post(
conn,
"/upload",
%{
"file" => file
}
)

assert %{"error" => "can't upload", "url" => nil} = json_response(response, 200)
end

Conclusion

This is the way how we write test for API without hitting to external services. It could not a good way, so if you guys have any idea, fell free to comment.

Thank you!