gdub01
Absinthe graphql: testing and displaying changeset errors
I’m not sure if this is the correct way to test an API call in with Absinthe. The code works, but I’m not sure if this is what I should be doing for each query?
@account %{email: "hey@you.com", password: "herewego"}
setup do
{:ok, account} = AccountResolver.create(@account, %{})
{:ok, token} = AccountResolver.login(@account, %{})
{:ok, %{token: token.token, id: account.id}}
end
test "Logged in user should be able to see their email", info do
queryDoc = %{
"operationName" => "account",
"query" => "query account { account (id: #{info.id}) { email } }",
"variables" => "{}"
}
conn = info.conn
|> put_req_header("authorization", "Bearer #{info.token}")
|> Map.put(:host, "localhost:4001")
|> Map.put(:body_params, queryDoc)
|> post("/api")
assert conn.state == :sent
assert conn.status == 200
assert String.contains?(conn.resp_body, "hey@you.com")
end
And in a second, not related question, I am transforming changeset errors into a string to be passed back to Absinthe on error:
defmodule Graphqlapi.ChangesetErrors do
def handle_changeset_errors(errors) do
Enum.map(errors, fn {field, detail} ->
"#{field} " <> render_detail(detail)
end)
|> Enum.join
end
def render_detail({message, values}) do
Enum.reduce values, message, fn {k, v}, acc ->
String.replace(acc, "%{#{k}}", to_string(v))
end
end
def render_detail(message) do
message
end
end
So that in my graphql resolver I have this:
def create(params, _info) do
case Auth.register(params) do
{:ok, account} -> {:ok, account}
{:error, changeset} -> {:error, ChangesetErrors.handle_changeset_errors(changeset.errors)}
end
end
With the idea being I don’t have to write error code twice. Is that a good plan or a bad one?
Marked As Solved
benwilson512
Hey there, I’m glad you’re using this library!
Let’s take each of your questions in turn.
Testing
The overall approach here is a very common way to do integration level testing. You may also find that the functions you build to handle the resolvers need unit testing if they’re more complex, but often complexity there gets extracted into service type functions that you’d want to unit test anyway.
A couple of things however would make the testing code you have a bit more idiomatic. Most of these things are phoenix conn test related, there isn’t much absinthe specific about this method of testing.
- use
|> post("/api", query_doc)instead of directly setting:body_params - parse the response body into JSON to look for the value you want instead of using
String.contains?. This is particularly important because GraphQL does not use HTTP status codes to indicate errors that may happen on a given field. So for example suppose youraccountfield returned an error"No account found for email hey@you.com". Your tests would actually pass right now, but clearly there’d be an error.
There’s also a few minor things about the testing here that are a bit confusing. Where does the conn part of info.conn come from? What content type header is being set? Why is the :host value being set? Are you using phoenix or just bare plug.
Error handling
It’s definitely common to want to handle changeset errors in a generic way, and definitely noto something you want to have to call explicitly in your resolvers over and over again.
The best solution at the moment is to build a wrapper function that handles this possible return value from the resolver function its wrapping. Here’s an example:
# in your field
resolve handle_errors(&SomeResolver.function/2)
# in your schema module somewhere, or imported thereinto
def handle_errors(fun) do
fn source, args, info ->
case Absinthe.Resolution.call(fun, source, args, info) do
{:error, %Ecto.Changeset{} = changeset} -> format_changeset(changeset)
val -> val
end
end
end
Now all that you have to do is wrap resolvers where you want changesets to be handled in a handle_errors call and you’re good to go. Having to still manually place handle_errors throughout your schema is a bit of an annoyance as well, and so we’re working to finalize a middleware pattern that will let you apply this pattern in an even more generic way. Until then, wrapper functions are the way to go.
Also Liked
gdub01
Okay - I’ve revised the test. Here’s what it looks like now if anyone is interested. Thanks again @benwilson512
@account %{email: "hey@you.com", password: "herewego"}
setup do
{:ok, account} = AccountResolver.create(@account, %{})
{:ok, token} = AccountResolver.login(@account, %{})
conn = build_conn()
|> put_req_header("authorization", "Bearer #{token.token}")
|> put_req_header("content-type", "application/json")
{:ok, %{conn: conn, id: account.id}}
end
defp query_skeleton(query, query_name) do
%{
"operationName" => "#{query_name}",
"query" => "query #{query_name} #{query}",
"variables" => "{}"
}
end
test "Logged in user should be able to see their email", context do
query = """
{
account (id: #{context.id}) {
email
}
}
"""
res = context.conn
|> post("/api", query_skeleton(query, "account"))
assert json_response(res, 200)["data"]["account"]["email"] == "hey@you.com"
end
test "Logged in user should not be able to see their password", context do
query = """
{
account (id: #{context.id}) {
password
}
}
"""
res = context.conn
|> post("/api", query_skeleton(query, "account"))
assert hd(json_response(res, 400)["errors"])["message"]
== "Cannot query field \"password\" on type \"Account\"."
end
tosbourn
I just wanted to say thanks to @gdub01 for asking about this and the answers that came from it. I am incredibly new to Elixir, GraphQL, and Absinthe and this helped a lot.
I’ve written up what I did in order to test both queries and mutations. Testing Absinthe with ExUnit.
The main difference is that the query param you send needs to look slightly different, these are the two helpers I came up with based on what was shared here before;
def mutation_skeleton(query) do
%{
"operationName" => "",
"query" => "#{query}",
"variables" => ""
}
end
def query_skeleton(query, query_name) do
%{
"operationName" => "#{query_name}",
"query" => "query #{query_name} #{query}",
"variables" => "{}"
}
end
cultofmetatron
this solution worked well for me based on brian’s answer
def handle_errors(fun) do
fn source, args, info ->
case Absinthe.Resolution.call(fun, source, args, info) do
{:error, %Ecto.Changeset{} = changeset} -> format_changeset(changeset)
val -> val
end
end
end
def format_changeset(changeset) do
#{:error, [email: {"has already been taken", []}]}
errors = changeset.errors
|> Enum.map(fn({key, {value, context}}) ->
[message: "#{key} #{value}", details: context]
end)
{:error, errors}
end
Popular in Questions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #javascript
- #code-sync
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance








